Compare commits

..

50 Commits

Author SHA1 Message Date
JzoNg
abedf2506f Merge branch 'main' into jzh 2026-03-27 17:01:27 +08:00
JzoNg
d01428b5bc feat(web): snippet graph draft sync 2026-03-27 16:02:47 +08:00
JzoNg
0de1f17e5c Merge branch 'main' into jzh 2026-03-27 15:23:49 +08:00
JzoNg
17d07a5a43 feat(web): init snippet graph 2026-03-27 15:23:03 +08:00
JzoNg
3bdbea99a3 Merge branch 'main' into jzh 2026-03-27 14:04:10 +08:00
JzoNg
b7683aedb1 Merge branch 'main' into jzh 2026-03-26 21:38:48 +08:00
JzoNg
515036e758 test(web): add tests for snippets 2026-03-26 21:38:22 +08:00
JzoNg
22b382527f feat(web): add snippet to workflow 2026-03-26 21:26:29 +08:00
JzoNg
2cfe4b5b86 feat(web): snippet graph data fetching 2026-03-26 21:11:09 +08:00
JzoNg
6876c8041c feat(web): snippet list data fetching in block selector 2026-03-26 20:58:42 +08:00
JzoNg
7de45584ce refactor: snippets list 2026-03-26 20:41:51 +08:00
JzoNg
5572d7c7e8 Merge branch 'main' into jzh 2026-03-26 20:10:47 +08:00
JzoNg
db0a2fe52e Merge branch 'main' into jzh 2026-03-26 16:29:44 +08:00
JzoNg
f0ae8d6167 fix(web): unused imports caused by merge 2026-03-26 16:28:56 +08:00
JzoNg
2514e181ba Merge branch 'main' into jzh 2026-03-26 16:16:10 +08:00
JzoNg
be2e6e9a14 Merge branch 'main' into jzh 2026-03-26 14:23:29 +08:00
JzoNg
875e2eac1b Merge branch 'main' into jzh 2026-03-26 08:38:57 +08:00
JzoNg
c3c73ceb1f Merge branch 'main' into jzh 2026-03-25 23:02:18 +08:00
JzoNg
6318bf0a2a feat(web): create snippet from workflow 2026-03-25 22:57:48 +08:00
JzoNg
5e1f252046 feat(web): selection context menu style update 2026-03-25 22:36:27 +08:00
JzoNg
df3b960505 fix(web): position of selection context menu in workflow graph 2026-03-25 22:02:50 +08:00
JzoNg
26bc108bf1 chore(web): tests for snippet info 2026-03-25 21:35:36 +08:00
JzoNg
a5cff32743 feat(web): snippet info operations 2026-03-25 21:29:06 +08:00
JzoNg
d418dd8eec Merge branch 'main' into jzh 2026-03-25 20:17:32 +08:00
JzoNg
61702fe346 Merge branch 'main' into jzh 2026-03-25 18:17:03 +08:00
JzoNg
43f0c780c3 Merge branch 'main' into jzh 2026-03-25 15:30:21 +08:00
JzoNg
30ebf2bfa9 Merge branch 'main' into jzh 2026-03-24 07:25:22 +08:00
JzoNg
7e3027b5f7 feat(web): snippet card usage info 2026-03-23 17:02:00 +08:00
JzoNg
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
JzoNg
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
JzoNg
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
JzoNg
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
JzoNg
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
JzoNg
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
JzoNg
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
JzoNg
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
JzoNg
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
JzoNg
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
JzoNg
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
JzoNg
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
JzoNg
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
JzoNg
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
120 changed files with 9789 additions and 5051 deletions

View File

@@ -42,7 +42,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
api-changed: ${{ steps.changes.outputs.api }}
e2e-changed: ${{ steps.changes.outputs.e2e }}
web-changed: ${{ steps.changes.outputs.web }}
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
@@ -60,16 +59,6 @@ jobs:
- 'web/**'
- '.github/workflows/web-tests.yml'
- '.github/actions/setup-web/**'
e2e:
- 'api/**'
- 'api/pyproject.toml'
- 'api/uv.lock'
- 'e2e/**'
- 'web/**'
- 'docker/docker-compose.middleware.yaml'
- 'docker/middleware.env.example'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'
vdb:
- 'api/core/rag/datasource/**'
- 'docker/**'
@@ -201,65 +190,6 @@ jobs:
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
web-e2e-run:
name: Run Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
uses: ./.github/workflows/web-e2e.yml
web-e2e-skip:
name: Skip Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: ubuntu-latest
steps:
- name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
web-e2e:
name: Web Full-Stack E2E
if: ${{ always() }}
needs:
- pre_job
- check-changes
- web-e2e-run
- web-e2e-skip
runs-on: ubuntu-latest
steps:
- name: Finalize Web Full-Stack E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
RUN_RESULT: ${{ needs.web-e2e-run.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web full-stack E2E ran successfully."
exit 0
fi
echo "Web full-stack E2E was required but finished with result: $RUN_RESULT" >&2
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
echo "Web full-stack E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
exit 1
style-check:
name: Style Check
needs: pre_job

View File

@@ -1,81 +0,0 @@
name: Web Full-Stack E2E
on:
workflow_call:
permissions:
contents: read
concurrency:
group: web-e2e-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
test:
name: Web Full-Stack E2E
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Install E2E package dependencies
working-directory: ./e2e
run: vp install --frozen-lockfile
- name: Setup UV and Python
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
python-version: "3.12"
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Start middleware stack
working-directory: ./e2e
run: vp run e2e:middleware:up
- name: Install Playwright browser
working-directory: ./e2e
run: vp run e2e:install
- name: Run source-api and built-web Cucumber E2E tests
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: "1"
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: cucumber-report
path: e2e/cucumber-report
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2e-logs
path: e2e/.logs
retention-days: 7
- name: Stop middleware stack
if: ${{ always() }}
working-directory: ./e2e
run: vp run e2e:middleware:down

View File

@@ -127,8 +127,6 @@ services:
restart: always
env_file:
- ./middleware.env
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Use the shared environment variables.
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}

6
e2e/.gitignore vendored
View File

@@ -1,6 +0,0 @@
node_modules/
.auth/
playwright-report/
test-results/
cucumber-report/
.logs/

View File

@@ -1,162 +0,0 @@
# E2E
This package contains the repository-level end-to-end tests for Dify.
This file is the canonical package guide for `e2e/`. Keep detailed workflow, architecture, debugging, and reporting documentation here. Keep `README.md` as a minimal pointer to this file so the two documents do not drift.
The suite uses Cucumber for scenario definitions and Playwright as the browser execution layer.
It tests:
- backend API started from source
- frontend served from the production artifact
- middleware services started from Docker
## Prerequisites
- Node.js `^22.22.1`
- `pnpm`
- `uv`
- Docker
Install Playwright browsers once:
```bash
cd e2e
pnpm install
pnpm e2e:install
pnpm check
```
Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package.
Common commands:
```bash
# authenticated-only regression (default excludes @fresh)
pnpm e2e
# full reset + fresh install + authenticated scenarios
pnpm e2e:full
# run a tagged subset
pnpm e2e -- --tags @smoke
# headed browser
pnpm e2e:headed -- --tags @smoke
# slow down browser actions for local debugging
E2E_SLOW_MO=500 pnpm e2e:headed -- --tags @smoke
```
Frontend artifact behavior:
- if `web/.next/BUILD_ID` exists, E2E reuses the existing build by default
- if you set `E2E_FORCE_WEB_BUILD=1`, E2E rebuilds the frontend before starting it
## Lifecycle
```mermaid
flowchart TD
A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"]
B --> C["support/web-server.ts starts or reuses frontend directly"]
C --> D["Cucumber loads config, steps, and support modules"]
D --> E["BeforeAll bootstraps shared auth state via /install"]
E --> F{"Which command is running?"}
F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"]
F -->|`pnpm e2e:full*`| H["Override tags to not @skip"]
G --> I["Per-scenario BrowserContext from shared browser"]
H --> I
I --> J["Failure artifacts written to cucumber-report/artifacts"]
```
Ownership is split like this:
- `scripts/setup.ts` is the single environment entrypoint for reset, middleware, backend, and frontend startup
- `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation
- `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown
- `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics
- `features/support/world.ts` owns per-scenario typed context
- `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root
Package layout:
- `features/`: Gherkin scenarios grouped by capability
- `features/step-definitions/`: domain-oriented step definitions
- `features/support/hooks.ts`: suite lifecycle, auth-state bootstrap, diagnostics
- `features/support/world.ts`: shared scenario context
- `support/web-server.ts`: typed frontend startup/reuse logic
- `scripts/setup.ts`: reset and service lifecycle commands
- `scripts/run-cucumber.ts`: Cucumber orchestration entrypoint
Behavior depends on instance state:
- uninitialized instance: completes install and stores authenticated state
- initialized instance: signs in and reuses authenticated state
Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance.
Reset all persisted E2E state:
```bash
pnpm e2e:reset
```
This removes:
- `docker/volumes/db/data`
- `docker/volumes/redis/data`
- `docker/volumes/weaviate`
- `docker/volumes/plugin_daemon`
- `e2e/.auth`
- `e2e/.logs`
- `e2e/cucumber-report`
Start the full middleware stack:
```bash
pnpm e2e:middleware:up
```
Stop the full middleware stack:
```bash
pnpm e2e:middleware:down
```
The middleware stack includes:
- PostgreSQL
- Redis
- Weaviate
- Sandbox
- SSRF proxy
- Plugin daemon
Fresh install verification:
```bash
pnpm e2e:full
```
Run the Cucumber suite against an already running middleware stack:
```bash
pnpm e2e:middleware:up
pnpm e2e
pnpm e2e:middleware:down
```
Artifacts and diagnostics:
- `cucumber-report/report.html`: HTML report
- `cucumber-report/report.json`: JSON report
- `cucumber-report/artifacts/`: failure screenshots and HTML captures
- `.logs/cucumber-api.log`: backend startup log
- `.logs/cucumber-web.log`: frontend startup log
Open the HTML report locally with:
```bash
open cucumber-report/report.html
```

View File

@@ -1,3 +0,0 @@
# E2E
Canonical documentation for this package lives in [AGENTS.md](./AGENTS.md).

View File

@@ -1,19 +0,0 @@
import type { IConfiguration } from '@cucumber/cucumber'
const config = {
format: [
'progress-bar',
'summary',
'html:./cucumber-report/report.html',
'json:./cucumber-report/report.json',
],
import: ['features/**/*.ts'],
parallel: 1,
paths: ['features/**/*.feature'],
tags: process.env.E2E_CUCUMBER_TAGS || 'not @fresh and not @skip',
timeout: 60_000,
} satisfies Partial<IConfiguration> & {
timeout: number
}
export default config

View File

@@ -1,10 +0,0 @@
@apps @authenticated
Feature: Create app
Scenario: Create a new blank app and redirect to the editor
Given I am signed in as the default E2E admin
When I open the apps console
And I start creating a blank app
And I enter a unique E2E app name
And I confirm app creation
Then I should land on the app editor
And I should see the "Orchestrate" text

View File

@@ -1,8 +0,0 @@
@smoke @authenticated
Feature: Authenticated app console
Scenario: Open the apps console with the shared authenticated state
Given I am signed in as the default E2E admin
When I open the apps console
Then I should stay on the apps console
And I should see the "Create from Blank" button
And I should not see the "Sign in" button

View File

@@ -1,7 +0,0 @@
@smoke @fresh
Feature: Fresh installation bootstrap
Scenario: Complete the initial installation bootstrap on a fresh instance
Given the last authentication bootstrap came from a fresh install
When I open the apps console
Then I should stay on the apps console
And I should see the "Create from Blank" button

View File

@@ -1,29 +0,0 @@
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { DifyWorld } from '../../support/world'
When('I start creating a blank app', async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible()
await page.getByRole('button', { name: 'Create from Blank' }).click()
})
When('I enter a unique E2E app name', async function (this: DifyWorld) {
const appName = `E2E App ${Date.now()}`
await this.getPage().getByPlaceholder('Give your app a name').fill(appName)
})
When('I confirm app creation', async function (this: DifyWorld) {
const createButton = this.getPage()
.getByRole('button', { name: /^Create(?:\s|$)/ })
.last()
await expect(createButton).toBeEnabled()
await createButton.click()
})
Then('I should land on the app editor', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/)
})

View File

@@ -1,11 +0,0 @@
import { Given } from '@cucumber/cucumber'
import type { DifyWorld } from '../../support/world'
Given('I am signed in as the default E2E admin', async function (this: DifyWorld) {
const session = await this.getAuthSession()
this.attach(
`Authenticated as ${session.adminEmail} using ${session.mode} flow at ${session.baseURL}.`,
'text/plain',
)
})

View File

@@ -1,23 +0,0 @@
import { Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { DifyWorld } from '../../support/world'
When('I open the apps console', async function (this: DifyWorld) {
await this.getPage().goto('/apps')
})
Then('I should stay on the apps console', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/)
})
Then('I should see the {string} button', async function (this: DifyWorld, label: string) {
await expect(this.getPage().getByRole('button', { name: label })).toBeVisible()
})
Then('I should not see the {string} button', async function (this: DifyWorld, label: string) {
await expect(this.getPage().getByRole('button', { name: label })).not.toBeVisible()
})
Then('I should see the {string} text', async function (this: DifyWorld, text: string) {
await expect(this.getPage().getByText(text)).toBeVisible({ timeout: 30_000 })
})

View File

@@ -1,12 +0,0 @@
import { Given } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import type { DifyWorld } from '../../support/world'
Given(
'the last authentication bootstrap came from a fresh install',
async function (this: DifyWorld) {
const session = await this.getAuthSession()
expect(session.mode).toBe('install')
},
)

View File

@@ -1,90 +0,0 @@
import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber'
import { chromium, type Browser } from '@playwright/test'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ensureAuthenticatedState } from '../../fixtures/auth'
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
import type { DifyWorld } from './world'
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts')
let browser: Browser | undefined
setDefaultTimeout(60_000)
const sanitizeForPath = (value: string) =>
value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-+|-+$/g, '')
const writeArtifact = async (
scenarioName: string,
extension: 'html' | 'png',
contents: Buffer | string,
) => {
const artifactPath = path.join(
artifactsDir,
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}.${extension}`,
)
await writeFile(artifactPath, contents)
return artifactPath
}
BeforeAll(async () => {
await mkdir(artifactsDir, { recursive: true })
browser = await chromium.launch({
headless: cucumberHeadless,
slowMo: cucumberSlowMo,
})
console.log(`[e2e] session cache bootstrap against ${baseURL}`)
await ensureAuthenticatedState(browser, baseURL)
})
Before(async function (this: DifyWorld, { pickle }) {
if (!browser) throw new Error('Shared Playwright browser is not available.')
await this.startAuthenticatedSession(browser)
this.scenarioStartedAt = Date.now()
const tags = pickle.tags.map((tag) => tag.name).join(' ')
console.log(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
})
After(async function (this: DifyWorld, { pickle, result }) {
const elapsedMs = this.scenarioStartedAt ? Date.now() - this.scenarioStartedAt : undefined
if (result?.status !== Status.PASSED && this.page) {
const screenshot = await this.page.screenshot({
fullPage: true,
})
const screenshotPath = await writeArtifact(pickle.name, 'png', screenshot)
this.attach(screenshot, 'image/png')
const html = await this.page.content()
const htmlPath = await writeArtifact(pickle.name, 'html', html)
this.attach(html, 'text/html')
if (this.consoleErrors.length > 0)
this.attach(`Console Errors:\n${this.consoleErrors.join('\n')}`, 'text/plain')
if (this.pageErrors.length > 0)
this.attach(`Page Errors:\n${this.pageErrors.join('\n')}`, 'text/plain')
this.attach(`Artifacts:\n${[screenshotPath, htmlPath].join('\n')}`, 'text/plain')
}
const status = result?.status || 'UNKNOWN'
console.log(
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
)
await this.closeSession()
})
AfterAll(async () => {
await browser?.close()
browser = undefined
})

View File

@@ -1,68 +0,0 @@
import { type IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber'
import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test'
import {
authStatePath,
readAuthSessionMetadata,
type AuthSessionMetadata,
} from '../../fixtures/auth'
import { baseURL, defaultLocale } from '../../test-env'
export class DifyWorld extends World {
context: BrowserContext | undefined
page: Page | undefined
consoleErrors: string[] = []
pageErrors: string[] = []
scenarioStartedAt: number | undefined
session: AuthSessionMetadata | undefined
constructor(options: IWorldOptions) {
super(options)
this.resetScenarioState()
}
resetScenarioState() {
this.consoleErrors = []
this.pageErrors = []
}
async startAuthenticatedSession(browser: Browser) {
this.resetScenarioState()
this.context = await browser.newContext({
baseURL,
locale: defaultLocale,
storageState: authStatePath,
})
this.context.setDefaultTimeout(30_000)
this.page = await this.context.newPage()
this.page.setDefaultTimeout(30_000)
this.page.on('console', (message: ConsoleMessage) => {
if (message.type() === 'error') this.consoleErrors.push(message.text())
})
this.page.on('pageerror', (error) => {
this.pageErrors.push(error.message)
})
}
getPage() {
if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.')
return this.page
}
async getAuthSession() {
this.session ??= await readAuthSessionMetadata()
return this.session
}
async closeSession() {
await this.context?.close()
this.context = undefined
this.page = undefined
this.session = undefined
this.scenarioStartedAt = undefined
this.resetScenarioState()
}
}
setWorldConstructor(DifyWorld)

View File

@@ -1,140 +0,0 @@
import type { Browser, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defaultBaseURL, defaultLocale } from '../test-env'
export type AuthSessionMetadata = {
adminEmail: string
baseURL: string
mode: 'install' | 'login'
usedInitPassword: boolean
}
const WAIT_TIMEOUT_MS = 120_000
const e2eRoot = fileURLToPath(new URL('..', import.meta.url))
export const authDir = path.join(e2eRoot, '.auth')
export const authStatePath = path.join(authDir, 'admin.json')
export const authMetadataPath = path.join(authDir, 'session.json')
export const adminCredentials = {
email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin@example.com',
name: process.env.E2E_ADMIN_NAME || 'E2E Admin',
password: process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin12345',
}
const initPassword = process.env.E2E_INIT_PASSWORD || 'E2eInit12345'
export const resolveBaseURL = (configuredBaseURL?: string) =>
configuredBaseURL || process.env.E2E_BASE_URL || defaultBaseURL
export const readAuthSessionMetadata = async () => {
const content = await readFile(authMetadataPath, 'utf8')
return JSON.parse(content) as AuthSessionMetadata
}
const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString()
const waitForPageState = async (page: Page) => {
const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' })
const signInButton = page.getByRole('button', { name: 'Sign in' })
const initPasswordField = page.getByLabel('Admin initialization password')
const deadline = Date.now() + WAIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (await installHeading.isVisible().catch(() => false)) return 'install' as const
if (await signInButton.isVisible().catch(() => false)) return 'login' as const
if (await initPasswordField.isVisible().catch(() => false)) return 'init' as const
await page.waitForTimeout(1_000)
}
throw new Error(`Unable to determine auth page state for ${page.url()}`)
}
const completeInitPasswordIfNeeded = async (page: Page) => {
const initPasswordField = page.getByLabel('Admin initialization password')
if (!(await initPasswordField.isVisible({ timeout: 3_000 }).catch(() => false))) return false
await initPasswordField.fill(initPassword)
await page.getByRole('button', { name: 'Validate' }).click()
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
})
return true
}
const completeInstall = async (page: Page, baseURL: string) => {
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
})
await page.getByLabel('Email address').fill(adminCredentials.email)
await page.getByLabel('Username').fill(adminCredentials.name)
await page.getByLabel('Password').fill(adminCredentials.password)
await page.getByRole('button', { name: 'Set up' }).click()
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
timeout: WAIT_TIMEOUT_MS,
})
}
const completeLogin = async (page: Page, baseURL: string) => {
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
})
await page.getByLabel('Email address').fill(adminCredentials.email)
await page.getByLabel('Password').fill(adminCredentials.password)
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
timeout: WAIT_TIMEOUT_MS,
})
}
export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => {
const baseURL = resolveBaseURL(configuredBaseURL)
await mkdir(authDir, { recursive: true })
const context = await browser.newContext({
baseURL,
locale: defaultLocale,
})
const page = await context.newPage()
try {
await page.goto(appURL(baseURL, '/install'), { waitUntil: 'networkidle' })
const usedInitPassword = await completeInitPasswordIfNeeded(page)
const pageState = await waitForPageState(page)
if (pageState === 'install') await completeInstall(page, baseURL)
else if (pageState === 'login') await completeLogin(page, baseURL)
else throw new Error(`Unexpected auth state "${pageState}" after initialization`)
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
})
await context.storageState({ path: authStatePath })
const metadata: AuthSessionMetadata = {
adminEmail: adminCredentials.email,
baseURL,
mode: pageState,
usedInitPassword,
}
await writeFile(authMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
} finally {
await context.close()
}
}

View File

@@ -1,34 +0,0 @@
{
"name": "dify-e2e",
"private": true,
"type": "module",
"scripts": {
"check": "vp check --fix",
"e2e": "tsx ./scripts/run-cucumber.ts",
"e2e:full": "tsx ./scripts/run-cucumber.ts --full",
"e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed",
"e2e:headed": "tsx ./scripts/run-cucumber.ts --headed",
"e2e:install": "playwright install --with-deps chromium",
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
"e2e:reset": "tsx ./scripts/setup.ts reset"
},
"devDependencies": {
"@cucumber/cucumber": "12.7.0",
"@playwright/test": "1.51.1",
"@types/node": "25.5.0",
"tsx": "4.21.0",
"typescript": "5.9.3",
"vite-plus": "latest"
},
"engines": {
"node": "^22.22.1"
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
}
}
}

2632
e2e/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +0,0 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { access, copyFile, readFile, writeFile } from 'node:fs/promises'
import net from 'node:net'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { sleep } from '../support/process'
type RunCommandOptions = {
command: string
args: string[]
cwd: string
env?: NodeJS.ProcessEnv
stdio?: 'inherit' | 'pipe'
}
type RunCommandResult = {
exitCode: number
stdout: string
stderr: string
}
type ForegroundProcessOptions = {
command: string
args: string[]
cwd: string
env?: NodeJS.ProcessEnv
}
export const rootDir = fileURLToPath(new URL('../..', import.meta.url))
export const e2eDir = path.join(rootDir, 'e2e')
export const apiDir = path.join(rootDir, 'api')
export const dockerDir = path.join(rootDir, 'docker')
export const webDir = path.join(rootDir, 'web')
export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml')
export const middlewareEnvFile = path.join(dockerDir, 'middleware.env')
export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example')
export const webEnvLocalFile = path.join(webDir, '.env.local')
export const webEnvExampleFile = path.join(webDir, '.env.example')
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')
export const isMainModule = (metaUrl: string) => {
const entrypoint = process.argv[1]
if (!entrypoint) return false
return pathToFileURL(entrypoint).href === metaUrl
}
export const runCommand = async ({
command,
args,
cwd,
env,
stdio = 'inherit',
}: RunCommandOptions): Promise<RunCommandResult> => {
const childProcess = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
},
stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
})
let stdout = ''
let stderr = ''
if (stdio === 'pipe') {
childProcess.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString()
})
childProcess.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString()
})
}
return await new Promise<RunCommandResult>((resolve, reject) => {
childProcess.once('error', reject)
childProcess.once('exit', (code) => {
resolve({
exitCode: code ?? 1,
stdout,
stderr,
})
})
})
}
export const runCommandOrThrow = async (options: RunCommandOptions) => {
const result = await runCommand(options)
if (result.exitCode !== 0) {
throw new Error(
`Command failed (${result.exitCode}): ${formatCommand(options.command, options.args)}`,
)
}
return result
}
const forwardSignalsToChild = (childProcess: ChildProcess) => {
const handleSignal = (signal: NodeJS.Signals) => {
if (childProcess.exitCode === null) childProcess.kill(signal)
}
const onSigint = () => handleSignal('SIGINT')
const onSigterm = () => handleSignal('SIGTERM')
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
return () => {
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
}
}
export const runForegroundProcess = async ({
command,
args,
cwd,
env,
}: ForegroundProcessOptions) => {
const childProcess = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
},
stdio: 'inherit',
})
const cleanupSignals = forwardSignalsToChild(childProcess)
const exitCode = await new Promise<number>((resolve, reject) => {
childProcess.once('error', reject)
childProcess.once('exit', (code) => {
resolve(code ?? 1)
})
})
cleanupSignals()
process.exit(exitCode)
}
export const ensureFileExists = async (filePath: string, exampleFilePath: string) => {
try {
await access(filePath)
} catch {
await copyFile(exampleFilePath, filePath)
}
}
export const ensureLineInFile = async (filePath: string, line: string) => {
const fileContent = await readFile(filePath, 'utf8')
const lines = fileContent.split(/\r?\n/)
const assignmentPrefix = line.includes('=') ? `${line.slice(0, line.indexOf('='))}=` : null
if (lines.includes(line)) return
if (assignmentPrefix && lines.some((existingLine) => existingLine.startsWith(assignmentPrefix)))
return
const normalizedContent = fileContent.endsWith('\n') ? fileContent : `${fileContent}\n`
await writeFile(filePath, `${normalizedContent}${line}\n`, 'utf8')
}
export const ensureWebEnvLocal = async () => {
await ensureFileExists(webEnvLocalFile, webEnvExampleFile)
const fileContent = await readFile(webEnvLocalFile, 'utf8')
const nextContent = fileContent.replaceAll('http://localhost:5001', 'http://127.0.0.1:5001')
if (nextContent !== fileContent) await writeFile(webEnvLocalFile, nextContent, 'utf8')
}
export const readSimpleDotenv = async (filePath: string) => {
const fileContent = await readFile(filePath, 'utf8')
const entries = fileContent
.split(/\r?\n/)
.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("'"))
) {
return [key, rawValue.slice(1, -1)]
}
return [key, rawValue]
})
return Object.fromEntries(entries)
}
export const waitForCondition = async ({
check,
description,
intervalMs,
timeoutMs,
}: {
check: () => Promise<boolean> | boolean
description: string
intervalMs: number
timeoutMs: number
}) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) return
await sleep(intervalMs)
}
throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms.`)
}
export const isTcpPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection({
host,
port,
})
const finish = (result: boolean) => {
socket.removeAllListeners()
socket.destroy()
resolve(result)
}
socket.setTimeout(timeoutMs)
socket.once('connect', () => finish(true))
socket.once('timeout', () => finish(false))
socket.once('error', () => finish(false))
})
}

View File

@@ -1,155 +0,0 @@
import { mkdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startWebServer, stopWebServer } from '../support/web-server'
import { waitForUrl, startLoggedProcess, stopManagedProcess } from '../support/process'
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
import { e2eDir, isMainModule, runCommand } from './common'
import { resetState, startMiddleware, stopMiddleware } from './setup'
type RunOptions = {
forwardArgs: string[]
full: boolean
headed: boolean
}
const parseArgs = (argv: string[]): RunOptions => {
let full = false
let headed = false
const forwardArgs: string[] = []
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--') {
forwardArgs.push(...argv.slice(index + 1))
break
}
if (arg === '--full') {
full = true
continue
}
if (arg === '--headed') {
headed = true
continue
}
forwardArgs.push(arg)
}
return {
forwardArgs,
full,
headed,
}
}
const hasCustomTags = (forwardArgs: string[]) =>
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
const main = async () => {
const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2))
const startMiddlewareForRun = full
const resetStateForRun = full
if (resetStateForRun) await resetState()
if (startMiddlewareForRun) await startMiddleware()
const cucumberReportDir = path.join(e2eDir, 'cucumber-report')
const logDir = path.join(e2eDir, '.logs')
await rm(cucumberReportDir, { force: true, recursive: true })
await mkdir(logDir, { recursive: true })
const apiProcess = await startLoggedProcess({
command: 'npx',
args: ['tsx', './scripts/setup.ts', 'api'],
cwd: e2eDir,
label: 'api server',
logFilePath: path.join(logDir, 'cucumber-api.log'),
})
let cleanupPromise: Promise<void> | undefined
const cleanup = async () => {
if (!cleanupPromise) {
cleanupPromise = (async () => {
await stopWebServer()
await stopManagedProcess(apiProcess)
if (startMiddlewareForRun) {
try {
await stopMiddleware()
} catch {
// Cleanup should continue even if middleware shutdown fails.
}
}
})()
}
await cleanupPromise
}
const onTerminate = () => {
void cleanup().finally(() => {
process.exit(1)
})
}
process.once('SIGINT', onTerminate)
process.once('SIGTERM', onTerminate)
try {
try {
await waitForUrl(`${apiURL}/health`, 180_000, 1_000)
} catch {
throw new Error(`API did not become ready at ${apiURL}/health.`)
}
await startWebServer({
baseURL,
command: 'npx',
args: ['tsx', './scripts/setup.ts', 'web'],
cwd: e2eDir,
logFilePath: path.join(logDir, 'cucumber-web.log'),
reuseExistingServer: reuseExistingWebServer,
timeoutMs: 300_000,
})
const cucumberEnv: NodeJS.ProcessEnv = {
...process.env,
CUCUMBER_HEADLESS: headed ? '0' : '1',
}
if (startMiddlewareForRun && !hasCustomTags(forwardArgs))
cucumberEnv.E2E_CUCUMBER_TAGS = 'not @skip'
else delete cucumberEnv.E2E_CUCUMBER_TAGS
const result = await runCommand({
command: 'npx',
args: [
'tsx',
'./node_modules/@cucumber/cucumber/bin/cucumber.js',
'--config',
'./cucumber.config.ts',
...forwardArgs,
],
cwd: e2eDir,
env: cucumberEnv,
})
process.exitCode = result.exitCode
} finally {
process.off('SIGINT', onTerminate)
process.off('SIGTERM', onTerminate)
await cleanup()
}
}
if (isMainModule(import.meta.url)) {
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}

View File

@@ -1,306 +0,0 @@
import { access, mkdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { waitForUrl } from '../support/process'
import {
apiDir,
apiEnvExampleFile,
dockerDir,
e2eDir,
ensureFileExists,
ensureLineInFile,
ensureWebEnvLocal,
isMainModule,
isTcpPortReachable,
middlewareComposeFile,
middlewareEnvExampleFile,
middlewareEnvFile,
readSimpleDotenv,
runCommand,
runCommandOrThrow,
runForegroundProcess,
waitForCondition,
webDir,
} from './common'
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
const middlewareDataPaths = [
path.join(dockerDir, 'volumes', 'db', 'data'),
path.join(dockerDir, 'volumes', 'plugin_daemon'),
path.join(dockerDir, 'volumes', 'redis', 'data'),
path.join(dockerDir, 'volumes', 'weaviate'),
]
const e2eStatePaths = [
path.join(e2eDir, '.auth'),
path.join(e2eDir, 'cucumber-report'),
path.join(e2eDir, '.logs'),
path.join(e2eDir, 'playwright-report'),
path.join(e2eDir, 'test-results'),
]
const composeArgs = [
'compose',
'-f',
middlewareComposeFile,
'--profile',
'postgresql',
'--profile',
'weaviate',
]
const getApiEnvironment = async () => {
const envFromExample = await readSimpleDotenv(apiEnvExampleFile)
return {
...envFromExample,
FLASK_APP: 'app.py',
}
}
const getServiceContainerId = async (service: string) => {
const result = await runCommandOrThrow({
command: 'docker',
args: ['compose', '-f', middlewareComposeFile, 'ps', '-q', service],
cwd: dockerDir,
stdio: 'pipe',
})
return result.stdout.trim()
}
const getContainerHealth = async (containerId: string) => {
const result = await runCommand({
command: 'docker',
args: ['inspect', '-f', '{{.State.Health.Status}}', containerId],
cwd: dockerDir,
stdio: 'pipe',
})
if (result.exitCode !== 0) return ''
return result.stdout.trim()
}
const printComposeLogs = async (services: string[]) => {
await runCommand({
command: 'docker',
args: ['compose', '-f', middlewareComposeFile, 'logs', ...services],
cwd: dockerDir,
})
}
const waitForDependency = async ({
description,
services,
wait,
}: {
description: string
services: string[]
wait: () => Promise<void>
}) => {
console.log(`Waiting for ${description}...`)
try {
await wait()
} catch (error) {
await printComposeLogs(services)
throw error
}
}
export const ensureWebBuild = async () => {
await ensureWebEnvLocal()
if (process.env.E2E_FORCE_WEB_BUILD === '1') {
await runCommandOrThrow({
command: 'pnpm',
args: ['run', 'build'],
cwd: webDir,
})
return
}
try {
await access(buildIdPath)
console.log('Reusing existing web build artifact.')
} catch {
await runCommandOrThrow({
command: 'pnpm',
args: ['run', 'build'],
cwd: webDir,
})
}
}
export const startWeb = async () => {
await ensureWebBuild()
await runForegroundProcess({
command: 'pnpm',
args: ['run', 'start'],
cwd: webDir,
env: {
HOSTNAME: '127.0.0.1',
PORT: '3000',
},
})
}
export const startApi = async () => {
const env = await getApiEnvironment()
await runCommandOrThrow({
command: 'uv',
args: ['run', '--project', '.', 'flask', 'upgrade-db'],
cwd: apiDir,
env,
})
await runForegroundProcess({
command: 'uv',
args: ['run', '--project', '.', 'flask', 'run', '--host', '127.0.0.1', '--port', '5001'],
cwd: apiDir,
env,
})
}
export const stopMiddleware = async () => {
await runCommandOrThrow({
command: 'docker',
args: [...composeArgs, 'down', '--remove-orphans'],
cwd: dockerDir,
})
}
export const resetState = async () => {
console.log('Stopping middleware services...')
try {
await stopMiddleware()
} catch {
// Reset should continue even if middleware is already stopped.
}
console.log('Removing persisted middleware data...')
await Promise.all(
middlewareDataPaths.map(async (targetPath) => {
await rm(targetPath, { force: true, recursive: true })
await mkdir(targetPath, { recursive: true })
}),
)
console.log('Removing E2E local state...')
await Promise.all(
e2eStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })),
)
console.log('E2E state reset complete.')
}
export const startMiddleware = async () => {
await ensureFileExists(middlewareEnvFile, middlewareEnvExampleFile)
await ensureLineInFile(middlewareEnvFile, 'COMPOSE_PROFILES=postgresql,weaviate')
console.log('Starting middleware services...')
await runCommandOrThrow({
command: 'docker',
args: [
...composeArgs,
'up',
'-d',
'db_postgres',
'redis',
'weaviate',
'sandbox',
'ssrf_proxy',
'plugin_daemon',
],
cwd: dockerDir,
})
const [postgresContainerId, redisContainerId] = await Promise.all([
getServiceContainerId('db_postgres'),
getServiceContainerId('redis'),
])
await waitForDependency({
description: 'PostgreSQL and Redis health checks',
services: ['db_postgres', 'redis'],
wait: () =>
waitForCondition({
check: async () => {
const [postgresStatus, redisStatus] = await Promise.all([
getContainerHealth(postgresContainerId),
getContainerHealth(redisContainerId),
])
return postgresStatus === 'healthy' && redisStatus === 'healthy'
},
description: 'PostgreSQL and Redis health checks',
intervalMs: 2_000,
timeoutMs: 240_000,
}),
})
await waitForDependency({
description: 'Weaviate readiness',
services: ['weaviate'],
wait: () => waitForUrl('http://127.0.0.1:8080/v1/.well-known/ready', 120_000, 2_000),
})
await waitForDependency({
description: 'sandbox health',
services: ['sandbox', 'ssrf_proxy'],
wait: () => waitForUrl('http://127.0.0.1:8194/health', 120_000, 2_000),
})
await waitForDependency({
description: 'plugin daemon port',
services: ['plugin_daemon'],
wait: () =>
waitForCondition({
check: async () => isTcpPortReachable('127.0.0.1', 5002),
description: 'plugin daemon port',
intervalMs: 2_000,
timeoutMs: 120_000,
}),
})
console.log('Full middleware stack is ready.')
}
const printUsage = () => {
console.log('Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|api|web>')
}
const main = async () => {
const command = process.argv[2]
switch (command) {
case 'api':
await startApi()
return
case 'middleware-down':
await stopMiddleware()
return
case 'middleware-up':
await startMiddleware()
return
case 'reset':
await resetState()
return
case 'web':
await startWeb()
return
default:
printUsage()
process.exitCode = 1
}
}
if (isMainModule(import.meta.url)) {
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
}

View File

@@ -1,171 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { createWriteStream, type WriteStream } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
type ManagedProcessOptions = {
command: string
args?: string[]
cwd: string
env?: NodeJS.ProcessEnv
label: string
logFilePath: string
}
export type ManagedProcess = {
childProcess: ChildProcess
label: string
logFilePath: string
logStream: WriteStream
}
export const sleep = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
export const isPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`http://${host}:${port}`, {
signal: controller.signal,
})
return response.status > 0
} finally {
clearTimeout(timeout)
}
} catch {
return false
}
}
export const waitForUrl = async (url: string, timeoutMs: number, intervalMs = 1_000) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), intervalMs)
try {
const response = await fetch(url, {
signal: controller.signal,
})
if (response.ok) return
} finally {
clearTimeout(timeout)
}
} catch {
// Keep polling until timeout.
}
await sleep(intervalMs)
}
throw new Error(`Timed out waiting for ${url} after ${timeoutMs}ms.`)
}
export const startLoggedProcess = async ({
command,
args = [],
cwd,
env,
label,
logFilePath,
}: ManagedProcessOptions): Promise<ManagedProcess> => {
await mkdir(dirname(logFilePath), { recursive: true })
const logStream = createWriteStream(logFilePath, { flags: 'a' })
const childProcess = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
},
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
})
const formattedCommand = [command, ...args].join(' ')
logStream.write(`[${new Date().toISOString()}] Starting ${label}: ${formattedCommand}\n`)
childProcess.stdout?.pipe(logStream, { end: false })
childProcess.stderr?.pipe(logStream, { end: false })
return {
childProcess,
label,
logFilePath,
logStream,
}
}
const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) =>
new Promise<void>((resolve) => {
if (childProcess.exitCode !== null) {
resolve()
return
}
const timeout = setTimeout(() => {
cleanup()
resolve()
}, timeoutMs)
const onExit = () => {
cleanup()
resolve()
}
const cleanup = () => {
clearTimeout(timeout)
childProcess.off('exit', onExit)
}
childProcess.once('exit', onExit)
})
const signalManagedProcess = (childProcess: ChildProcess, signal: NodeJS.Signals) => {
const { pid } = childProcess
if (!pid) return
try {
if (process.platform !== 'win32') {
process.kill(-pid, signal)
return
}
childProcess.kill(signal)
} catch {
// Best-effort shutdown. Cleanup continues even when the process is already gone.
}
}
export const stopManagedProcess = async (managedProcess?: ManagedProcess) => {
if (!managedProcess) return
const { childProcess, logStream } = managedProcess
if (childProcess.exitCode === null) {
signalManagedProcess(childProcess, 'SIGTERM')
await waitForProcessExit(childProcess, 5_000)
}
if (childProcess.exitCode === null) {
signalManagedProcess(childProcess, 'SIGKILL')
await waitForProcessExit(childProcess, 5_000)
}
childProcess.stdout?.unpipe(logStream)
childProcess.stderr?.unpipe(logStream)
childProcess.stdout?.destroy()
childProcess.stderr?.destroy()
await new Promise<void>((resolve) => {
logStream.end(() => resolve())
})
}

View File

@@ -1,83 +0,0 @@
import type { ManagedProcess } from './process'
import { isPortReachable, startLoggedProcess, stopManagedProcess, waitForUrl } from './process'
type WebServerStartOptions = {
baseURL: string
command: string
args?: string[]
cwd: string
logFilePath: string
reuseExistingServer: boolean
timeoutMs: number
}
let activeProcess: ManagedProcess | undefined
const getUrlHostAndPort = (url: string) => {
const parsedUrl = new URL(url)
const isHttps = parsedUrl.protocol === 'https:'
return {
host: parsedUrl.hostname,
port: parsedUrl.port ? Number(parsedUrl.port) : isHttps ? 443 : 80,
}
}
export const startWebServer = async ({
baseURL,
command,
args = [],
cwd,
logFilePath,
reuseExistingServer,
timeoutMs,
}: WebServerStartOptions) => {
const { host, port } = getUrlHostAndPort(baseURL)
if (reuseExistingServer && (await isPortReachable(host, port))) return
activeProcess = await startLoggedProcess({
command,
args,
cwd,
label: 'web server',
logFilePath,
})
let startupError: Error | undefined
activeProcess.childProcess.once('error', (error) => {
startupError = error
})
activeProcess.childProcess.once('exit', (code, signal) => {
if (startupError) return
startupError = new Error(
`Web server exited before readiness (code: ${code ?? 'unknown'}, signal: ${signal ?? 'none'}).`,
)
})
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (startupError) {
await stopManagedProcess(activeProcess)
activeProcess = undefined
throw startupError
}
try {
await waitForUrl(baseURL, 1_000, 250)
return
} catch {
// Continue polling until timeout or child exit.
}
}
await stopManagedProcess(activeProcess)
activeProcess = undefined
throw new Error(`Timed out waiting for web server readiness at ${baseURL} after ${timeoutMs}ms.`)
}
export const stopWebServer = async () => {
await stopManagedProcess(activeProcess)
activeProcess = undefined
}

View File

@@ -1,12 +0,0 @@
export const defaultBaseURL = 'http://127.0.0.1:3000'
export const defaultApiURL = 'http://127.0.0.1:5001'
export const defaultLocale = 'en-US'
export const baseURL = process.env.E2E_BASE_URL || defaultBaseURL
export const apiURL = process.env.E2E_API_URL || defaultApiURL
export const cucumberHeadless = process.env.CUCUMBER_HEADLESS !== '0'
export const cucumberSlowMo = Number(process.env.E2E_SLOW_MO || 0)
export const reuseExistingWebServer = process.env.E2E_REUSE_WEB_SERVER
? process.env.E2E_REUSE_WEB_SERVER !== '0'
: !process.env.CI

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": false,
"resolveJsonModule": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node", "@playwright/test", "@cucumber/cucumber"],
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["./**/*.ts"],
"exclude": [
"./node_modules",
"./playwright-report",
"./test-results",
"./.auth",
"./cucumber-report",
"./.logs"
]
}

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'vite-plus'
export default defineConfig({
lint: {
options: {
typeAware: true,
typeCheck: true,
denyWarnings: true,
},
},
fmt: {
singleQuote: true,
semi: false,
},
})

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
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' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="evaluation" />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="orchestrate" />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,285 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
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?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
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?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
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(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@@ -0,0 +1,62 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,197 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
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'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
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')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
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')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<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>
</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>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@@ -0,0 +1,55 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -36,6 +39,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -54,11 +59,13 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
const mockRefetch = vi.fn()
const mockFetchNextPage = vi.fn()
const mockFetchSnippetNextPage = vi.fn()
const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +107,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -112,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({
}),
}))
const mockSnippetServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
const defaultSnippetData = {
pages: [{
data: [
{
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
author: '',
updatedAt: '2024-01-02 10:00',
usage: '19',
icon: '🪄',
iconBackground: '#E0EAFF',
status: undefined,
},
],
total: 1,
}],
}
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: () => ({
data: defaultSnippetData,
isLoading: mockSnippetServiceState.isLoading,
isFetching: mockSnippetServiceState.isFetching,
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
fetchNextPage: mockFetchSnippetNextPage,
hasNextPage: mockSnippetServiceState.hasNextPage,
error: mockSnippetServiceState.error,
}),
useCreateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useImportSnippetDSLMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useConfirmSnippetImportMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}))
vi.mock('@/service/tag', () => ({
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
}))
@@ -133,13 +192,21 @@ vi.mock('@/next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
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 React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@@ -188,9 +255,8 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
}
describe('List', () => {
@@ -202,284 +268,62 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
mockQueryState.isCreatedByMe = false
mockSnippetServiceState.error = null
mockSnippetServiceState.hasNextPage = false
mockSnippetServiceState.isLoading = false
mockSnippetServiceState.isFetching = false
mockSnippetServiceState.isFetchingNextPage = false
intersectionCallback = null
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
fireEvent.click(screen.getByText('app.types.all'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle search input change', () => {
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
expect(mockSetQuery).toHaveBeenCalled()
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
it('should render and close the DSL import modal when a file is dropped', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
@@ -489,98 +333,50 @@ describe('List', () => {
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
describe('Snippets Mode', () => {
it('should render the snippets create card and snippet card from the real query hook', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
mockSnippetServiceState.hasNextPage = true
renderList({ pageType: 'snippets' })
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
it('should not fetch the next snippet page when no more data is available', () => {
renderList({ pageType: 'snippets' })
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
act(() => {
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,15 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: import('./app-type-filter-shared').AppListCategory
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
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('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{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={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{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>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@@ -0,0 +1,128 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{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-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@@ -12,14 +12,24 @@ import dynamic from '@/next/dynamic'
import { fetchAppDetail } from '@/service/explore'
import List from './list'
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
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 Apps = () => {
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@@ -103,7 +113,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@@ -1,13 +1,13 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -16,15 +16,21 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import dynamic from '@/next/dynamic'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import SnippetCard from '../snippets/components/snippet-card'
import SnippetCreateCard from '../snippets/components/snippet-create-card'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
import StudioRouteSwitch from './studio-route-switch'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
@@ -33,25 +39,17 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -61,18 +59,22 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -83,15 +85,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -104,159 +106,214 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
const {
data: snippetData,
isLoading: isSnippetListLoading,
isFetching: isSnippetListFetching,
isFetchingNextPage: isSnippetListFetchingNextPage,
fetchNextPage: fetchSnippetNextPage,
hasNextPage: hasSnippetNextPage,
error: snippetError,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword: snippetKeywords || undefined,
}, {
enabled: !isAppsPage,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
const currentError = isAppsPage ? error : snippetError
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
if (currentError) {
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
fetchNextPage()
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
if (isAppsPage)
fetchNextPage()
else
fetchSnippetNextPage()
}
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
setSnippetKeywords(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywordsInput(value)
handleSnippetSearch(value)
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const snippetItems = useMemo(() => {
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
}, [snippetData?.pages])
const showSkeleton = isAppsPage
? (isLoading || (isFetching && data?.pages?.length === 0))
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = snippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
return (
<>
<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 className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<label className="mr-2 flex h-7 items-center space-x-2">
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
<div className="text-sm font-normal text-text-secondary">
{t('showMyCreatedAppsOnly', { ns: 'app' })}
</div>
</label>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
isAppsPage && !hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
{!isAppsPage && isSnippetListFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'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' })}
>
@@ -264,17 +321,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@@ -0,0 +1,44 @@
'use client'
import type { StudioPageType } from '.'
import Link from '@/next/link'
import { cn } from '@/utils/classnames'
type Props = {
pageType: StudioPageType
appsLabel: string
snippetsLabel: string
}
const StudioRouteSwitch = ({
pageType,
appsLabel,
snippetsLabel,
}: Props) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
export default StudioRouteSwitch

View File

@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
),
}))
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should search, add metrics, and create a batch history record', async () => {
vi.useFakeTimers()
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'does-not-exist' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'faith' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1300)
})
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
vi.useRealTimers()
})
it('should render time placeholders and hide the value row for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
let groupId = ''
let itemId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'workflow'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
})
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'pipeline'
const resourceId = 'dataset-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
})
it('should support time fields and clear values for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
group: 'quality',
badges: ['LLM', 'Built-in'],
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
group: 'quality',
badges: ['LLM', 'Retrieval'],
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
group: 'quality',
badges: ['LLM'],
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
group: 'operations',
badges: ['System'],
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
group: 'operations',
badges: ['System'],
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
group: 'operations',
badges: ['Workflow'],
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'time')
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'pipeline') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippet') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@@ -0,0 +1,635 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@@ -0,0 +1,117 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'before'
| 'after'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
group: string
badges: string[]
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
mappings: CustomMetricMapping[]
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
badges: string[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
}
export type JudgmentConditionGroup = {
id: string
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@@ -107,7 +107,7 @@ const AppNav = () => {
icon={<RiRobot2Line className="h-4 w-4" />}
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
text={t('menus.apps', { ns: 'common' })}
activeSegment={['apps', 'app']}
activeSegment={['apps', 'app', 'snippets']}
link="/apps"
curNav={appDetail}
navigationItems={navItems}

View File

@@ -14,7 +14,7 @@ const HeaderWrapper = ({
children,
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
// Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')

View File

@@ -0,0 +1,218 @@
import type { SnippetDetailPayload } from '@/models/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetPage from '..'
import { useSnippetDetailStore } from '../store'
const mockUseSnippetInit = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
}))
vi.mock('../hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'snippet-1',
flowType: 'snippet',
fileSettings: {},
}),
}))
vi.mock('../hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: vi.fn(),
syncWorkflowDraftWhenPageClose: vi.fn(),
}),
}))
vi.mock('../hooks/use-snippet-refresh-draft', () => ({
useSnippetRefreshDraft: () => ({
handleRefreshWorkflowDraft: vi.fn(),
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
push: vi.fn(),
}),
}))
vi.mock('@/service/use-snippets', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
return {
...actual,
useUpdateSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: vi.fn(),
isPending: false,
}),
}
})
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: undefined,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
MediaType: { mobile: 'mobile', desktop: 'desktop' },
}))
vi.mock('@/app/components/workflow', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-default-context">{children}</div>
),
WorkflowWithInnerContext: ({ children, viewport }: { children: React.ReactNode, viewport?: { zoom?: number } }) => (
<div data-testid="workflow-inner-context">
<span data-testid="workflow-viewport-zoom">{viewport?.zoom ?? 'none'}</span>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/context', () => ({
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="workflow-context-provider">{children}</div>
),
}))
vi.mock('@/app/components/app-sidebar', () => ({
default: ({
renderHeader,
renderNavigation,
}: {
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}) => (
<div data-testid="app-sidebar">
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
</div>
),
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
<button type="button" onClick={onClick}>{name}</button>
),
}))
vi.mock('@/app/components/workflow/panel', () => ({
default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => (
<div data-testid="workflow-panel">
<div data-testid="workflow-panel-left">{components?.left}</div>
<div data-testid="workflow-panel-right">{components?.right}</div>
</div>
),
}))
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
return {
...actual,
initialNodes: (nodes: unknown[]) => nodes,
initialEdges: (edges: unknown[]) => edges,
}
})
vi.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockSnippetDetail: SnippetDetailPayload = {
snippet: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
author: 'Evan',
updatedAt: 'Updated 2h ago',
usage: 'Used 19 times',
icon: '🪄',
iconBackground: '#E0EAFF',
status: 'Draft',
},
graph: {
viewport: { x: 0, y: 0, zoom: 1 },
nodes: [],
edges: [],
},
inputFields: [
{
type: PipelineInputVarType.textInput,
label: 'Blog URL',
variable: 'blog_url',
required: true,
options: [],
placeholder: 'Paste a source article URL',
max_length: 256,
},
],
uiMeta: {
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
},
}
describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
})
})
it('should render the snippet detail shell', () => {
render(<SnippetPage snippetId="snippet-1" />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('A static snippet mock.')).toBeInTheDocument()
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
expect(screen.getByTestId('workflow-viewport-zoom').textContent).toBe('1')
})
it('should open the input field panel and editor', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.inputFieldButton/i })[0])
expect(screen.getAllByText('snippet.panelTitle').length).toBeGreaterThan(0)
fireEvent.click(screen.getAllByRole('button', { name: /datasetPipeline\.inputFieldPanel\.addInputField/i })[0])
expect(screen.getAllByText('datasetPipeline.inputFieldPanel.addInputField').length).toBeGreaterThan(1)
})
it('should toggle the publish menu', () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
})
it('should render loading fallback when snippet data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,
isLoading: false,
})
render(<SnippetPage snippetId="missing-snippet" />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import SnippetCreateCard from '../snippet-create-card'
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockCreateMutate: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: mockToastSuccess,
error: mockToastError,
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutate: mockCreateMutate,
isPending: false,
}),
}))
vi.mock('../snippet-import-dsl-dialog', () => ({
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: (snippetId: string) => void }) => {
if (!show)
return null
return (
<div data-testid="snippet-import-dsl-dialog">
<button type="button" onClick={() => onSuccess?.('snippet-imported')}>Complete Import</button>
<button type="button" onClick={onClose}>Close Import</button>
</div>
)
},
}))
describe('SnippetCreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Create From Blank', () => {
it('should open the create dialog and create a snippet from the modal', async () => {
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
options?.onSuccess?.({ id: 'snippet-123' })
})
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My Snippet' },
})
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
target: { value: 'Useful snippet description' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
expect(mockCreateMutate).toHaveBeenCalledWith({
body: {
name: 'My Snippet',
description: 'Useful snippet description',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
})
})
describe('Import DSL', () => {
it('should open the import dialog and navigate when the import succeeds', async () => {
render(<SnippetCreateCard />)
fireEvent.click(screen.getByRole('button', { name: 'app.importDSL' }))
expect(screen.getByTestId('snippet-import-dsl-dialog')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Complete Import' }))
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
})
})
})
})

View File

@@ -0,0 +1,54 @@
'use client'
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { SnippetInputField } from '@/models/snippet'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
type SnippetInputFieldEditorProps = {
field?: SnippetInputField | null
onClose: () => void
onSubmit: (field: SnippetInputField) => void
}
const SnippetInputFieldEditor = ({
field,
onClose,
onSubmit,
}: SnippetInputFieldEditorProps) => {
const { t } = useTranslation()
const initialData = useMemo(() => {
return convertToInputFieldFormData(field || undefined)
}, [field])
const handleSubmit = useCallback((value: FormData) => {
onSubmit(convertFormDataToINputField(value))
}, [onSubmit])
return (
<div className="relative mr-1 flex h-fit max-h-full w-[min(400px,calc(100vw-24px))] flex-col overflow-y-auto rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9">
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</div>
<button
type="button"
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</button>
<InputFieldForm
initialData={initialData}
supportFile
onCancel={onClose}
onSubmit={handleSubmit}
isEditMode={!!field}
/>
</div>
)
}
export default SnippetInputFieldEditor

View File

@@ -0,0 +1,119 @@
'use client'
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
type SnippetInputFieldPanelProps = {
fields: SnippetInputField[]
onClose: () => void
onAdd: () => void
onEdit: (field: SnippetInputField) => void
onRemove: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const toInputFields = (list: SortableItem[]) => {
return list.map((item) => {
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
return field
})
}
const SnippetInputFieldPanel = ({
fields,
onClose,
onAdd,
onEdit,
onRemove,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetInputFieldPanelProps) => {
const { t } = useTranslation('snippet')
const primaryFields = fields.slice(0, 2)
const secondaryFields = fields.slice(2)
const handlePrimaryRemove = useCallback((index: number) => {
onRemove(index)
}, [onRemove])
const handleSecondaryRemove = useCallback((index: number) => {
onRemove(index + primaryFields.length)
}, [onRemove, primaryFields.length])
const handlePrimaryEdit = useCallback((id: string) => {
const field = primaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, primaryFields])
const handleSecondaryEdit = useCallback((id: string) => {
const field = secondaryFields.find(item => item.variable === id)
if (field)
onEdit(field)
}, [onEdit, secondaryFields])
return (
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
<div className="min-w-0">
<div className="text-text-primary system-xl-semibold">
{t('panelTitle')}
</div>
<div className="pt-1 text-text-tertiary system-sm-regular">
{t('panelDescription')}
</div>
</div>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line h-4 w-4" />
</button>
</div>
<div className="px-4 pb-2">
<Button variant="secondary" size="small" className="w-full justify-center gap-1" onClick={onAdd}>
<span aria-hidden className="i-ri-add-line h-4 w-4" />
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
</Button>
</div>
<div className="flex grow flex-col overflow-y-auto">
<div className="px-4 pb-1 pt-2 text-text-secondary system-xs-semibold-uppercase">
{t('panelPrimaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-2"
inputFields={primaryFields}
onListSortChange={list => onPrimarySortChange(toInputFields(list))}
onRemoveField={handlePrimaryRemove}
onEditField={handlePrimaryEdit}
/>
<div className="px-4 py-2">
<Divider type="horizontal" className="bg-divider-subtle" />
</div>
<div className="px-4 pb-1 text-text-secondary system-xs-semibold-uppercase">
{t('panelSecondaryGroup')}
</div>
<FieldListContainer
className="flex flex-col gap-y-1 px-4 pb-4"
inputFields={secondaryFields}
onListSortChange={list => onSecondarySortChange(toInputFields(list))}
onRemoveField={handleSecondaryRemove}
onEditField={handleSecondaryEdit}
/>
</div>
</div>
)
}
export default memo(SnippetInputFieldPanel)

View File

@@ -0,0 +1,29 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const PublishMenu = ({
uiMeta,
}: {
uiMeta: SnippetDetailUIModel
}) => {
const { t } = useTranslation('snippet')
return (
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
<div className="text-text-tertiary system-xs-semibold-uppercase">
{t('publishMenuCurrentDraft')}
</div>
<div className="pt-1 text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
{t('publishButton')}
</Button>
</div>
)
}
export default PublishMenu

View File

@@ -0,0 +1,58 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Link from '@/next/link'
type Props = {
snippet: SnippetListItem
}
const SnippetCard = ({ snippet }: Props) => {
const { t } = useTranslation('snippet')
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{!snippet.is_published && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
Draft
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<AppIcon
size="large"
iconType={snippet.icon_info.icon_type}
icon={snippet.icon_info.icon}
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
</div>
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updated_at}</span>
{!snippet.is_published && (
<>
<span>·</span>
<span className="truncate">{t('usageCount', { count: snippet.use_count })}</span>
</>
)}
</div>
</article>
</Link>
)
}
export default SnippetCard

View File

@@ -0,0 +1,106 @@
'use client'
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
import PublishMenu from './publish-menu'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
fields: SnippetInputField[]
uiMeta: SnippetDetailUIModel
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetChildren = ({
fields,
uiMeta,
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
onToggleInputPanel,
onTogglePublishMenu,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetChildrenProps) => {
return (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
<SnippetHeader
inputFieldCount={fields.length}
onToggleInputPanel={onToggleInputPanel}
onTogglePublishMenu={onTogglePublishMenu}
/>
<SnippetWorkflowPanel
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
{isPublishMenuOpen && (
<div className="absolute right-3 top-14 z-20">
<PublishMenu uiMeta={uiMeta} />
</div>
)}
{isInputPanelOpen && (
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
<div className="pointer-events-auto h-full xl:hidden">
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
</div>
</div>
)}
{isEditorOpen && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-black/10 px-3 xl:hidden">
<div className="pointer-events-auto w-full max-w-md">
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
</div>
</div>
)}
</>
)
}
export default SnippetChildren

View File

@@ -0,0 +1,109 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
useCreateSnippetMutation,
} from '@/service/use-snippets'
import SnippetImportDSLDialog from './snippet-import-dsl-dialog'
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isImportDSLDialogOpen, setIsImportDSLDialogOpen] = useState(false)
const handleCreateFromBlank = () => {
setIsCreateDialogOpen(true)
}
const handleImportDSL = () => {
setIsImportDSLDialogOpen(true)
}
const handleCreateSnippet = ({
name,
description,
icon,
}: {
name: string
description: string
icon: AppIconSelection
}) => {
createSnippetMutation.mutate({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: (snippet) => {
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
setIsCreateDialogOpen(false)
push(`/snippets/${snippet.id}/orchestrate`)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('createFailed'))
},
})
}
return (
<>
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
<button
type="button"
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={createSnippetMutation.isPending}
onClick={handleCreateFromBlank}
>
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('createFromBlank')}
</button>
<button
type="button"
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={handleImportDSL}
>
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</button>
</div>
</div>
{isCreateDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateDialogOpen}
isSubmitting={createSnippetMutation.isPending}
onClose={() => setIsCreateDialogOpen(false)}
onConfirm={handleCreateSnippet}
/>
)}
{isImportDSLDialogOpen && (
<SnippetImportDSLDialog
show={isImportDSLDialogOpen}
onClose={() => setIsImportDSLDialogOpen(false)}
onSuccess={(snippetId) => {
setIsImportDSLDialogOpen(false)
push(`/snippets/${snippetId}/orchestrate`)
}}
/>
)}
</>
)
}
export default SnippetCreateCard

View File

@@ -0,0 +1,61 @@
'use client'
import { useTranslation } from 'react-i18next'
type SnippetHeaderProps = {
inputFieldCount: number
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
}
const SnippetHeader = ({
inputFieldCount,
onToggleInputPanel,
onTogglePublishMenu,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
return (
<div className="absolute right-3 top-3 z-20 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-secondary shadow-xs backdrop-blur"
onClick={onToggleInputPanel}
>
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
<span className="rounded-md border border-divider-deep px-1.5 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
{inputFieldCount}
</span>
</button>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-text-accent shadow-xs backdrop-blur"
>
<span aria-hidden className="i-ri-play-mini-fill h-4 w-4" />
<span className="text-[13px] font-medium leading-4">{t('testRunButton')}</span>
<span className="rounded-md bg-state-accent-active px-1.5 py-0.5 text-[10px] font-semibold leading-3 text-text-accent">R</span>
</button>
<div className="relative">
<button
type="button"
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
onClick={onTogglePublishMenu}
>
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</button>
</div>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg text-text-tertiary shadow-xs"
>
<span aria-hidden className="i-ri-more-2-line h-4 w-4" />
</button>
</div>
)
}
export default SnippetHeader

View File

@@ -0,0 +1,266 @@
'use client'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import {
DSLImportMode,
DSLImportStatus,
} from '@/models/app'
import {
useConfirmSnippetImportMutation,
useImportSnippetDSLMutation,
} from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import ShortcutsName from '../../workflow/shortcuts-name'
type SnippetImportDSLDialogProps = {
show: boolean
onClose: () => void
onSuccess?: (snippetId: string) => void
}
const SnippetImportDSLTab = {
FromFile: 'from-file',
FromURL: 'from-url',
} as const
type SnippetImportDSLTabValue = typeof SnippetImportDSLTab[keyof typeof SnippetImportDSLTab]
const SnippetImportDSLDialog = ({
show,
onClose,
onSuccess,
}: SnippetImportDSLDialogProps) => {
const { t } = useTranslation()
const importSnippetDSLMutation = useImportSnippetDSLMutation()
const confirmSnippetImportMutation = useConfirmSnippetImportMutation()
const [currentFile, setCurrentFile] = useState<File>()
const [fileContent, setFileContent] = useState<string>()
const [currentTab, setCurrentTab] = useState<SnippetImportDSLTabValue>(SnippetImportDSLTab.FromFile)
const [dslUrlValue, setDslUrlValue] = useState('')
const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false)
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
const [importId, setImportId] = useState<string>()
const isImporting = importSnippetDSLMutation.isPending || confirmSnippetImportMutation.isPending
const readFile = (file: File) => {
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result
setFileContent(content as string)
}
reader.readAsText(file)
}
const handleFile = (file?: File) => {
setCurrentFile(file)
if (file)
readFile(file)
if (!file)
setFileContent('')
}
const completeImport = (snippetId?: string, status: string = DSLImportStatus.COMPLETED) => {
if (!snippetId) {
toast.error(t('importFailed', { ns: 'snippet' }))
return
}
if (status === DSLImportStatus.COMPLETED_WITH_WARNINGS)
toast.warning(t('newApp.appCreateDSLWarning', { ns: 'app' }))
else
toast.success(t('importSuccess', { ns: 'snippet' }))
onSuccess?.(snippetId)
}
const handleImportResponse = (response: {
id: string
status: string
snippet_id?: string
imported_dsl_version?: string
current_dsl_version?: string
}) => {
if (response.status === DSLImportStatus.COMPLETED || response.status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
completeImport(response.snippet_id, response.status)
return
}
if (response.status === DSLImportStatus.PENDING) {
setVersions({
importedVersion: response.imported_dsl_version ?? '',
systemVersion: response.current_dsl_version ?? '',
})
setImportId(response.id)
setShowVersionMismatchDialog(true)
return
}
toast.error(t('importFailed', { ns: 'snippet' }))
}
const handleCreate = () => {
if (currentTab === SnippetImportDSLTab.FromFile && !currentFile)
return
if (currentTab === SnippetImportDSLTab.FromURL && !dslUrlValue)
return
importSnippetDSLMutation.mutate({
mode: currentTab === SnippetImportDSLTab.FromFile ? DSLImportMode.YAML_CONTENT : DSLImportMode.YAML_URL,
yamlContent: currentTab === SnippetImportDSLTab.FromFile ? fileContent || '' : undefined,
yamlUrl: currentTab === SnippetImportDSLTab.FromURL ? dslUrlValue : undefined,
}, {
onSuccess: handleImportResponse,
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
const { run: handleCreateSnippet } = useDebounceFn(handleCreate, { wait: 300 })
const handleConfirmImport = () => {
if (!importId)
return
confirmSnippetImportMutation.mutate({
importId,
}, {
onSuccess: (response) => {
setShowVersionMismatchDialog(false)
completeImport(response.snippet_id)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
},
})
}
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!show || showVersionMismatchDialog || isImporting)
return
if ((currentTab === SnippetImportDSLTab.FromFile && currentFile) || (currentTab === SnippetImportDSLTab.FromURL && dslUrlValue))
handleCreateSnippet()
})
const buttonDisabled = useMemo(() => {
if (isImporting)
return true
if (currentTab === SnippetImportDSLTab.FromFile)
return !currentFile
return !dslUrlValue
}, [currentFile, currentTab, dslUrlValue, isImporting])
return (
<>
<Dialog open={show} onOpenChange={open => !open && onClose()}>
<DialogContent className="w-[520px] p-0">
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('importFromDSL', { ns: 'app' })}
</DialogTitle>
<DialogCloseButton className="right-5 top-6 h-8 w-8" />
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
{[
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
].map(tab => (
<button
key={tab.key}
type="button"
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" />
)}
</button>
))}
</div>
<div className="px-6 py-4">
{currentTab === SnippetImportDSLTab.FromFile && (
<Uploader
className="mt-0"
file={currentFile}
updateFile={handleFile}
/>
)}
{currentTab === SnippetImportDSLTab.FromURL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
onChange={e => setDslUrlValue(e.target.value)}
/>
</div>
)}
</div>
<div className="flex justify-end px-6 py-5">
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button
disabled={buttonDisabled}
variant="primary"
onClick={handleCreateSnippet}
className="gap-1"
>
<span>{t('newApp.Create', { ns: 'app' })}</span>
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={showVersionMismatchDialog} onOpenChange={open => !open && setShowVersionMismatchDialog(false)}>
<DialogContent className="w-[480px]">
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
</DialogTitle>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />
<div>
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
<span className="system-md-medium">{versions?.importedVersion}</span>
</div>
<div>
{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" disabled={isImporting} onClick={() => setShowVersionMismatchDialog(false)}>
{t('newApp.Cancel', { ns: 'app' })}
</Button>
<Button variant="primary" destructive disabled={isImporting} onClick={handleConfirmImport}>
{t('newApp.Confirm', { ns: 'app' })}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default SnippetImportDSLDialog

View File

@@ -0,0 +1,218 @@
'use client'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField, SnippetSection } from '@/models/snippet'
import {
RiFlaskFill,
RiFlaskLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import {
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppSideBar from '@/app/components/app-sidebar'
import NavLink from '@/app/components/app-sidebar/nav-link'
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import { toast } from '@/app/components/base/ui/toast'
import Evaluation from '@/app/components/evaluation'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useConfigsMap } from '../hooks/use-configs-map'
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
import { useSnippetDetailStore } from '../store'
import SnippetChildren from './snippet-children'
type SnippetMainProps = {
payload: SnippetDetailPayload
snippetId: string
section: SnippetSection
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiTerminalWindowLine,
selected: RiTerminalWindowFill,
}
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
normal: RiFlaskLine,
selected: RiFlaskFill,
}
const SnippetMain = ({
payload,
snippetId,
section,
nodes,
edges,
viewport,
}: SnippetMainProps) => {
const { t } = useTranslation('snippet')
const { graph, snippet, uiMeta } = payload
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
const {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
} = useNodesSyncDraft(snippetId)
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const configsMap = useConfigsMap(snippetId)
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
const {
editingField,
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
closeEditor,
openEditor,
reset,
setInputPanelOpen,
toggleInputPanel,
togglePublishMenu,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
isEditorOpen: state.isEditorOpen,
isInputPanelOpen: state.isInputPanelOpen,
isPublishMenuOpen: state.isPublishMenuOpen,
closeEditor: state.closeEditor,
openEditor: state.openEditor,
reset: state.reset,
setInputPanelOpen: state.setInputPanelOpen,
toggleInputPanel: state.toggleInputPanel,
togglePublishMenu: state.togglePublishMenu,
})))
useEffect(() => {
reset()
}, [reset, snippetId])
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
const primaryFields = useMemo(() => fields.slice(0, 2), [fields])
const secondaryFields = useMemo(() => fields.slice(2), [fields])
const handlePrimarySortChange = (newFields: SnippetInputField[]) => {
setFields([...newFields, ...secondaryFields])
}
const handleSecondarySortChange = (newFields: SnippetInputField[]) => {
setFields([...primaryFields, ...newFields])
}
const handleRemoveField = (index: number) => {
setFields(current => current.filter((_, currentIndex) => currentIndex !== index))
}
const handleSubmitField = (field: SnippetInputField) => {
const originalVariable = editingField?.variable
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
if (duplicated) {
toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }))
return
}
if (originalVariable)
setFields(current => current.map(item => item.variable === originalVariable ? field : item))
else
setFields(current => [...current, field])
closeEditor()
}
const handleToggleInputPanel = () => {
if (isInputPanelOpen)
closeEditor()
toggleInputPanel()
}
const handleCloseInputPanel = () => {
closeEditor()
setInputPanelOpen(false)
}
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,
configsMap,
}
}, [configsMap, doSyncWorkflowDraft, handleRefreshWorkflowDraft, syncWorkflowDraftWhenPageClose])
return (
<div className="relative flex h-full overflow-hidden bg-background-body">
<AppSideBar
navigation={[]}
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
renderNavigation={mode => (
<>
<NavLink
mode={mode}
name={t('sectionOrchestrate')}
iconMap={ORCHESTRATE_ICONS}
href={`/snippets/${snippetId}/orchestrate`}
active={section === 'orchestrate'}
/>
<NavLink
mode={mode}
name={t('sectionEvaluation')}
iconMap={EVALUATION_ICONS}
href={`/snippets/${snippetId}/evaluation`}
active={section === 'evaluation'}
/>
</>
)}
/>
<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">
{section === 'evaluation'
? (
<Evaluation resourceType="snippet" resourceId={snippetId} />
)
: (
<WorkflowWithInnerContext
nodes={nodes}
edges={edges}
viewport={viewport ?? graph.viewport}
hooksStore={hooksStore}
>
<SnippetChildren
fields={fields}
uiMeta={uiMeta}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
isPublishMenuOpen={isPublishMenuOpen}
onToggleInputPanel={handleToggleInputPanel}
onTogglePublishMenu={togglePublishMenu}
onCloseInputPanel={handleCloseInputPanel}
onOpenEditor={openEditor}
onCloseEditor={closeEditor}
onSubmitField={handleSubmitField}
onRemoveField={handleRemoveField}
onPrimarySortChange={handlePrimarySortChange}
onSecondarySortChange={handleSecondarySortChange}
/>
</WorkflowWithInnerContext>
)}
</div>
</div>
</div>
)
}
export default SnippetMain

View File

@@ -0,0 +1,111 @@
'use client'
import type { PanelProps } from '@/app/components/workflow/panel'
import type { SnippetInputField } from '@/models/snippet'
import { memo, useMemo } from 'react'
import Panel from '@/app/components/workflow/panel'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
type SnippetWorkflowPanelProps = {
fields: SnippetInputField[]
editingField: SnippetInputField | null
isEditorOpen: boolean
isInputPanelOpen: boolean
onCloseInputPanel: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
onRemoveField: (index: number) => void
onPrimarySortChange: (fields: SnippetInputField[]) => void
onSecondarySortChange: (fields: SnippetInputField[]) => void
}
const SnippetPanelOnLeft = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
return (
<div className="hidden xl:flex">
{isEditorOpen && (
<SnippetInputFieldEditor
field={editingField}
onClose={onCloseEditor}
onSubmit={onSubmitField}
/>
)}
{isInputPanelOpen && (
<SnippetInputFieldPanel
fields={fields}
onClose={onCloseInputPanel}
onAdd={() => onOpenEditor()}
onEdit={onOpenEditor}
onRemove={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
)}
</div>
)
}
const SnippetWorkflowPanel = ({
fields,
editingField,
isEditorOpen,
isInputPanelOpen,
onCloseInputPanel,
onOpenEditor,
onCloseEditor,
onSubmitField,
onRemoveField,
onPrimarySortChange,
onSecondarySortChange,
}: SnippetWorkflowPanelProps) => {
const panelProps: PanelProps = useMemo(() => {
return {
components: {
left: (
<SnippetPanelOnLeft
fields={fields}
editingField={editingField}
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
onCloseInputPanel={onCloseInputPanel}
onOpenEditor={onOpenEditor}
onCloseEditor={onCloseEditor}
onSubmitField={onSubmitField}
onRemoveField={onRemoveField}
onPrimarySortChange={onPrimarySortChange}
onSecondarySortChange={onSecondarySortChange}
/>
),
},
}
}, [
editingField,
fields,
isEditorOpen,
isInputPanelOpen,
onCloseEditor,
onCloseInputPanel,
onOpenEditor,
onPrimarySortChange,
onRemoveField,
onSecondarySortChange,
onSubmitField,
])
return <Panel {...panelProps} />
}
export default memo(SnippetWorkflowPanel)

View File

@@ -0,0 +1,162 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSnippetInit } from '../use-snippet-init'
const mockWorkflowStoreSetState = vi.fn()
const mockSetPublishedAt = vi.fn()
const mockSetDraftUpdatedAt = vi.fn()
const mockSetSyncWorkflowDraftHash = vi.fn()
const mockUseSnippetApiDetail = vi.fn()
const mockUseSnippetDraftWorkflow = vi.fn()
const mockUseSnippetDefaultBlockConfigs = vi.fn()
const mockUseSnippetPublishedWorkflow = vi.fn()
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
setState: mockWorkflowStoreSetState,
getState: () => ({
setDraftUpdatedAt: mockSetDraftUpdatedAt,
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
setPublishedAt: mockSetPublishedAt,
}),
}),
}))
vi.mock('@/service/use-snippets', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
return {
...actual,
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
}
})
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess),
useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess),
useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess),
}))
describe('useSnippetInit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetApiDetail.mockReturnValue({
data: {
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'A static snippet mock.',
type: 'node',
is_published: false,
version: '1',
use_count: 0,
icon_info: {
icon_type: null,
icon: '🪄',
icon_background: '#E0EAFF',
},
input_fields: [],
created_at: 1_712_300_000,
updated_at: 1_712_300_000,
author: 'Evan',
},
error: null,
isLoading: false,
})
mockUseSnippetDraftWorkflow.mockReturnValue({
data: undefined,
isLoading: false,
})
mockUseSnippetDefaultBlockConfigs.mockReturnValue({
data: undefined,
})
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: undefined,
})
})
it('should return snippet detail query result', () => {
const { result } = renderHook(() => useSnippetInit('snippet-1'))
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
expect(result.current.data?.snippet.id).toBe('snippet-1')
expect(result.current.data?.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
expect(result.current.isLoading).toBe(false)
})
it('should sync draft metadata into workflow store', () => {
mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => {
onSuccess?.({
updated_at: 1_712_345_678,
hash: 'draft-hash',
})
return { data: undefined, isLoading: false }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
})
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 }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
llm: { model: 'gpt-4.1' },
code: { language: 'python3' },
},
})
})
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 }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
llm: { model: 'gpt-4.1' },
},
})
})
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: undefined, isLoading: false }
})
renderHook(() => useSnippetInit('snippet-1'))
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
})
it('should stay loading while draft workflow is still fetching', () => {
mockUseSnippetDraftWorkflow.mockReturnValue({
data: undefined,
isLoading: true,
})
const { result } = renderHook(() => useSnippetInit('snippet-1'))
expect(result.current.data).toBeUndefined()
expect(result.current.isLoading).toBe(true)
})
})

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react'
import { useStore } from '@/app/components/workflow/store'
import { Resolution, TransferMethod } from '@/types/app'
import { FlowType } from '@/types/common'
export const useConfigsMap = (snippetId: string) => {
const fileUploadConfig = useStore(s => s.fileUploadConfig)
return useMemo(() => {
return {
flowId: snippetId,
flowType: FlowType.snippet,
fileSettings: {
image: {
enabled: false,
detail: Resolution.high,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
fileUploadConfig,
},
}
}, [fileUploadConfig, snippetId])
}

View File

@@ -0,0 +1,125 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
import { consoleClient } from '@/service/client'
import { postWithKeepalive } from '@/service/fetch'
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'
}
export const useNodesSyncDraft = (snippetId: string) => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
const getPostParams = useCallback(() => {
const {
getNodes,
edges,
transform,
} = store.getState()
const nodes = getNodes().filter(node => !node.data?._isTempNode)
const [x, y, zoom] = transform
const { syncWorkflowDraftHash } = workflowStore.getState()
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 {
url: `/snippets/${snippetId}/workflows/draft`,
params: {
graph: {
nodes: producedNodes,
edges: producedEdges,
viewport: { x, y, zoom },
},
hash: syncWorkflowDraftHash,
},
}
}, [snippetId, store, workflowStore])
const syncWorkflowDraftWhenPageClose = useCallback(() => {
if (getNodesReadOnly())
return
const postParams = getPostParams()
if (postParams)
postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params)
}, [getNodesReadOnly, getPostParams])
const performSync = useCallback(async (
notRefreshWhenSyncError?: boolean,
callback?: SyncDraftCallback,
) => {
if (getNodesReadOnly())
return
const postParams = getPostParams()
if (!postParams)
return
const {
setDraftUpdatedAt,
setSyncWorkflowDraftHash,
} = workflowStore.getState()
try {
const response = await consoleClient.snippets.syncDraftWorkflow({
params: { snippetId },
body: postParams.params,
})
setSyncWorkflowDraftHash(response.hash)
setDraftUpdatedAt(response.updated_at)
callback?.onSuccess?.()
}
catch (error: unknown) {
if (isSyncConflictError(error) && !error.bodyUsed) {
error.json().then((err) => {
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
handleRefreshWorkflowDraft()
})
}
callback?.onError?.()
}
finally {
callback?.onSettled?.()
}
}, [getNodesReadOnly, getPostParams, handleRefreshWorkflowDraft, snippetId, workflowStore])
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
return {
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
}
}

View File

@@ -0,0 +1,75 @@
import { useMemo } from 'react'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
useSnippetDefaultBlockConfigs,
useSnippetDraftWorkflow,
useSnippetPublishedWorkflow,
} from '@/service/use-snippet-workflows'
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
}
return acc
}, {} as Record<string, unknown>)
}
const isNotFoundError = (error: unknown) => {
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
}
export const useSnippetInit = (snippetId: string) => {
const workflowStore = useWorkflowStore()
const snippetApiDetail = useSnippetApiDetail(snippetId)
const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => {
const {
setDraftUpdatedAt,
setSyncWorkflowDraftHash,
} = workflowStore.getState()
setDraftUpdatedAt(draftWorkflow.updated_at)
setSyncWorkflowDraftHash(draftWorkflow.hash)
})
useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => {
workflowStore.setState({
nodesDefaultConfigs: normalizeNodesDefaultConfigs(nodesDefaultConfigs),
})
})
useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => {
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
})
const data = useMemo(() => {
if (snippetApiDetail.data && !draftWorkflowQuery.isLoading)
return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data)
if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error))
return null
return undefined
}, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, snippetApiDetail.data, snippetApiDetail.error])
return {
...snippetApiDetail,
data,
isLoading: snippetApiDetail.isLoading || draftWorkflowQuery.isLoading,
}
}

View File

@@ -0,0 +1,41 @@
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
import { useCallback } from 'react'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { consoleClient } from '@/service/client'
export const useSnippetRefreshDraft = (snippetId: string) => {
const workflowStore = useWorkflowStore()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const handleRefreshWorkflowDraft = useCallback(() => {
const {
setDraftUpdatedAt,
setIsSyncingWorkflowDraft,
setSyncWorkflowDraftHash,
} = workflowStore.getState()
if (!snippetId)
return
setIsSyncingWorkflowDraft(true)
consoleClient.snippets.draftWorkflow({
params: { snippetId },
}).then((response) => {
handleUpdateWorkflowCanvas({
...response.graph,
nodes: response.graph?.nodes || [],
edges: response.graph?.edges || [],
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
} as WorkflowDataUpdater)
setSyncWorkflowDraftHash(response.hash)
setDraftUpdatedAt(response.updated_at)
}).finally(() => {
setIsSyncingWorkflowDraft(false)
})
}, [handleUpdateWorkflowCanvas, snippetId, workflowStore])
return {
handleRefreshWorkflowDraft,
}
}

View File

@@ -0,0 +1,72 @@
'use client'
import type { SnippetSection } from '@/models/snippet'
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 SnippetMain from './components/snippet-main'
import { useSnippetInit } from './hooks/use-snippet-init'
type SnippetPageProps = {
snippetId: string
section?: SnippetSection
}
const SnippetPage = ({
snippetId,
section = 'orchestrate',
}: SnippetPageProps) => {
const { data, isLoading } = useSnippetInit(snippetId)
const nodesData = useMemo(() => {
if (!data)
return []
return initialNodes(data.graph.nodes, data.graph.edges)
}, [data])
const edgesData = useMemo(() => {
if (!data)
return []
return initialEdges(data.graph.edges, data.graph.nodes)
}, [data])
if (!data || isLoading) {
return (
<div className="flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
return (
<WorkflowWithDefaultContext
edges={edgesData}
nodes={nodesData}
>
<SnippetMain
key={snippetId}
snippetId={snippetId}
section={section}
payload={data}
nodes={nodesData}
edges={edgesData}
viewport={data.graph.viewport}
/>
</WorkflowWithDefaultContext>
)
}
const SnippetPageWrapper = (props: SnippetPageProps) => {
return (
<WorkflowContextProvider>
<SnippetPage {...props} />
</WorkflowContextProvider>
)
}
export default SnippetPageWrapper

View File

@@ -0,0 +1,44 @@
'use client'
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
import { create } from 'zustand'
type SnippetDetailUIState = {
activeSection: SnippetSection
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPreviewMode: boolean
isEditorOpen: boolean
editingField: SnippetInputField | null
setActiveSection: (section: SnippetSection) => void
setInputPanelOpen: (value: boolean) => void
toggleInputPanel: () => void
setPublishMenuOpen: (value: boolean) => void
togglePublishMenu: () => void
setPreviewMode: (value: boolean) => void
openEditor: (field?: SnippetInputField | null) => void
closeEditor: () => void
reset: () => void
}
const initialState = {
activeSection: 'orchestrate' as SnippetSection,
isInputPanelOpen: false,
isPublishMenuOpen: false,
isPreviewMode: false,
editingField: null,
isEditorOpen: false,
}
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
...initialState,
setActiveSection: activeSection => set({ activeSection }),
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
reset: () => set(initialState),
}))

View File

@@ -11,20 +11,53 @@ let latestNodes: Node[] = []
let latestHistoryEvent: string | undefined
const mockGetNodesReadOnly = vi.fn()
const mockHandleNodesCopy = vi.fn()
const mockHandleNodesDuplicate = vi.fn()
const mockHandleNodesDelete = vi.fn()
const mockHandleNodesDuplicate = vi.fn()
const mockPush = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockCreateSnippetMutateAsync = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutateAsync: mockCreateSnippetMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('../hooks', async () => {
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
return {
...actual,
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
useNodesInteractions: () => ({
handleNodesCopy: mockHandleNodesCopy,
handleNodesDuplicate: mockHandleNodesDuplicate,
handleNodesDelete: mockHandleNodesDelete,
handleNodesDuplicate: mockHandleNodesDuplicate,
}),
useNodesReadOnly: () => ({
getNodesReadOnly: mockGetNodesReadOnly,
}),
}
})
@@ -82,8 +115,13 @@ describe('SelectionContextmenu', () => {
mockGetNodesReadOnly.mockReset()
mockGetNodesReadOnly.mockReturnValue(false)
mockHandleNodesCopy.mockReset()
mockHandleNodesDuplicate.mockReset()
mockHandleNodesDelete.mockReset()
mockHandleNodesDuplicate.mockReset()
mockPush.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
mockCreateSnippetMutateAsync.mockReset()
mockSyncDraftWorkflow.mockReset()
})
it('should not render when selectionMenu is absent', () => {
@@ -98,6 +136,19 @@ describe('SelectionContextmenu', () => {
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
]
const { store } = renderSelectionMenu({ nodes })
const container = document.querySelector('#workflow-container') as HTMLDivElement
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 16,
y: 24,
left: 16,
top: 24,
right: 816,
bottom: 624,
width: 800,
height: 600,
toJSON: () => ({}),
})
act(() => {
store.setState({ selectionMenu: { clientX: 780, clientY: 590 } })
@@ -196,6 +247,107 @@ describe('SelectionContextmenu', () => {
vi.useRealTimers()
})
it('should render selection actions and delegate copy, duplicate, and delete', () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
]
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
expect(screen.getByTestId('selection-contextmenu-item-copy')).toHaveTextContent('workflow.common.copy')
expect(screen.getByTestId('selection-contextmenu-item-duplicate')).toHaveTextContent('workflow.common.duplicate')
expect(screen.getByTestId('selection-contextmenu-item-delete')).toHaveTextContent('common.operation.delete')
fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy'))
act(() => {
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate'))
act(() => {
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete'))
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1)
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
})
it('should create a snippet with the selected graph and redirect to the snippet detail page', async () => {
mockCreateSnippetMutateAsync.mockResolvedValue({ id: 'snippet-123' })
mockSyncDraftWorkflow.mockResolvedValue({ result: 'success' })
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 120, y: 60 }, width: 40, height: 20 }),
createNode({ id: 'n2', selected: true, position: { x: 260, y: 120 }, width: 60, height: 30 }),
createNode({ id: 'n3', selected: false, position: { x: 500, y: 300 }, width: 40, height: 20 }),
]
const edges = [
createEdge({ id: 'e1', source: 'n1', target: 'n2' }),
createEdge({ id: 'e2', source: 'n2', target: 'n3' }),
]
const { store } = renderSelectionMenu({ nodes, edges })
act(() => {
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet'))
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My snippet' },
})
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
await waitFor(() => {
expect(mockCreateSnippetMutateAsync).toHaveBeenCalledWith({
body: expect.objectContaining({
name: 'My snippet',
}),
})
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-123' },
body: {
graph: {
nodes: [
expect.objectContaining({
id: 'n1',
position: { x: 0, y: 0 },
selected: false,
data: expect.objectContaining({ selected: false }),
}),
expect.objectContaining({
id: 'n2',
position: { x: 140, y: 60 },
selected: false,
data: expect.objectContaining({ selected: false }),
}),
],
edges: [
expect.objectContaining({
id: 'e1',
source: 'n1',
target: 'n2',
selected: false,
}),
],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
})
it('should distribute selected nodes horizontally', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),

View File

@@ -71,6 +71,10 @@ export const useTabs = ({
name: t('tabs.start', { ns: 'workflow' }),
show: shouldShowStartTab,
disabled: shouldDisableStartTab,
}, {
key: TabsEnum.Snippets,
name: t('tabs.snippets', { ns: 'workflow' }),
show: true,
}]
return tabConfigs.filter(tab => tab.show)
@@ -100,6 +104,7 @@ export const useTabs = ({
preferredOrder.push(TabsEnum.Sources)
if (!noStart)
preferredOrder.push(TabsEnum.Start)
preferredOrder.push(TabsEnum.Snippets)
for (const tabKey of preferredOrder) {
const validKey = getValidTabKey(tabKey)

View File

@@ -15,6 +15,7 @@ import type {
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
@@ -32,6 +33,7 @@ import SearchBox from '@/app/components/plugins/marketplace/search-box'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, isTriggerNode } from '../types'
import { useTabs } from './hooks'
import Snippets from './snippets'
import Tabs from './tabs'
import { TabsEnum } from './types'
@@ -88,6 +90,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
const { t } = useTranslation()
const nodes = useNodes()
const [searchText, setSearchText] = useState('')
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
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.
@@ -119,28 +122,6 @@ const NodeSelector: FC<NodeSelectorProps> = ({
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen)
setSearchText('')
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const {
activeTab,
setActiveTab,
@@ -154,10 +135,51 @@ const NodeSelector: FC<NodeSelectorProps> = ({
hasUserInputNode,
forceEnableStartTab,
})
const open = openFromProps === undefined ? localOpen : openFromProps
const handleOpenChange = useCallback((newOpen: boolean) => {
setLocalOpen(newOpen)
if (!newOpen) {
setSearchText('')
setSnippetsLoading(false)
}
else if (activeTab === TabsEnum.Snippets) {
setSnippetsLoading(true)
}
if (onOpenChange)
onOpenChange(newOpen)
}, [activeTab, onOpenChange])
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
if (disabled)
return
e.stopPropagation()
handleOpenChange(!open)
}, [handleOpenChange, open, disabled])
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
handleOpenChange(false)
onSelect(type, pluginDefaultValue)
}, [handleOpenChange, onSelect])
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
setActiveTab(newActiveTab)
}, [setActiveTab])
if (open && newActiveTab === TabsEnum.Snippets)
setSnippetsLoading(true)
}, [open, setActiveTab])
useEffect(() => {
if (!snippetsLoading)
return
const timer = window.setTimeout(() => {
setSnippetsLoading(false)
}, 200)
return () => {
window.clearTimeout(timer)
}
}, [snippetsLoading])
const searchPlaceholder = useMemo(() => {
if (activeTab === TabsEnum.Start)
@@ -171,6 +193,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
if (activeTab === TabsEnum.Sources)
return t('tabs.searchDataSource', { ns: 'workflow' })
if (activeTab === TabsEnum.Snippets)
return t('tabs.searchSnippets', { ns: 'workflow' })
return ''
}, [activeTab, t])
@@ -257,6 +281,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
inputClassName="grow"
/>
)}
{activeTab === TabsEnum.Snippets && (
<Input
showLeftIcon
showClearIcon
autoFocus
value={searchText}
placeholder={searchPlaceholder}
onChange={e => setSearchText(e.target.value)}
onClear={() => setSearchText('')}
/>
)}
</div>
)}
onSelect={handleSelect}
@@ -268,6 +303,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
noTools={noTools}
onTagsChange={setTags}
forceShowStartContent={forceShowStartContent}
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
/>
</div>
</PortalToFollowElemContent>

View File

@@ -0,0 +1,161 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Snippets from '../index'
const mockUseInfiniteSnippetList = vi.fn()
const mockHandleInsertSnippet = vi.fn()
const mockHandleCreateSnippet = vi.fn()
const mockHandleOpenCreateSnippetDialog = vi.fn()
const mockHandleCloseCreateSnippetDialog = vi.fn()
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
...actual,
useInfiniteScroll: vi.fn(),
}
})
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: (...args: unknown[]) => mockUseInfiniteSnippetList(...args),
}))
vi.mock('../use-insert-snippet', () => ({
useInsertSnippet: () => ({
handleInsertSnippet: mockHandleInsertSnippet,
}),
}))
vi.mock('../use-create-snippet', () => ({
useCreateSnippet: () => ({
createSnippetMutation: { isPending: false },
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
handleCreateSnippet: mockHandleCreateSnippet,
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen: false,
isCreatingSnippet: false,
}),
}))
vi.mock('../../../create-snippet-dialog', () => ({
default: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="create-snippet-dialog" /> : null,
}))
describe('Snippets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseInfiniteSnippetList.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
})
describe('Rendering', () => {
it('should render loading skeleton when loading', () => {
const { container } = render(<Snippets loading searchText="" />)
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
})
it('should render empty state when snippet list is empty', () => {
render(<Snippets searchText="" />)
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should render snippet rows from infinite list data', () => {
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [{
data: [{
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
input_fields: [],
created_at: 1,
updated_at: 2,
}],
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
render(<Snippets searchText="customer" />)
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'customer',
is_published: true,
})
expect(screen.getByText('Customer Review')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should delegate create action from empty state', () => {
render(<Snippets searchText="" />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1)
})
it('should delegate insert action when snippet item is clicked', () => {
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [{
data: [{
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
input_fields: [],
created_at: 1,
updated_at: 2,
}],
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
render(<Snippets searchText="" />)
fireEvent.click(screen.getByText('Customer Review'))
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
})
})
})

View File

@@ -0,0 +1,64 @@
import type { PublishedSnippetListItem } from '../snippet-detail-card'
import { render, screen } from '@testing-library/react'
import SnippetDetailCard from '../snippet-detail-card'
const mockUseSnippetPublishedWorkflow = vi.fn()
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
created_at: 1,
updated_at: 2,
...overrides,
})
describe('SnippetDetailCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
describe('Rendering', () => {
it('should render snippet summary information', () => {
render(<SnippetDetailCard snippet={createSnippet()} />)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.getByText('Snippet description')).toBeInTheDocument()
expect(screen.getByText('Evan')).toBeInTheDocument()
})
it('should render unique block icons from published workflow graph', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [
{ data: { type: 'llm' } },
{ data: { type: 'code' } },
{ data: { type: 'llm' } },
{ data: { type: 'unknown' } },
],
},
},
})
const { container } = render(<SnippetDetailCard snippet={createSnippet()} />)
expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2)
})
})
})

View File

@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetEmptyState from '../snippet-empty-state'
describe('SnippetEmptyState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty state copy and create action', () => {
const handleCreate = vi.fn()
render(<SnippetEmptyState onCreate={handleCreate} />)
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCreate when create button is clicked', () => {
const handleCreate = vi.fn()
render(<SnippetEmptyState onCreate={handleCreate} />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
expect(handleCreate).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,85 @@
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 => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
created_at: 1,
updated_at: 2,
...overrides,
})
describe('SnippetListItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render snippet name', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.queryByText('Evan')).not.toBeInTheDocument()
})
it('should render author when hovered', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
)
expect(screen.getByText('Evan')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should forward click and hover handlers', () => {
const handleClick = vi.fn()
const handleMouseEnter = vi.fn()
const handleMouseLeave = vi.fn()
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>,
)
const item = screen.getByText('Customer Review').closest('div')!
fireEvent.mouseEnter(item)
fireEvent.mouseLeave(item)
fireEvent.click(item)
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
expect(handleMouseLeave).toHaveBeenCalledTimes(1)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,139 @@
import { act, renderHook } from '@testing-library/react'
import { useCreateSnippet } from '../use-create-snippet'
const mockPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
describe('useCreateSnippet', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('State', () => {
it('should open and close create snippet dialog', () => {
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(true)
act(() => {
result.current.handleCloseCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
})
})
describe('Create Flow', () => {
it('should create snippet, sync draft workflow, and navigate on success', async () => {
mockMutateAsync.mockResolvedValue({ id: 'snippet-123' })
mockSyncDraftWorkflow.mockResolvedValue(undefined)
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: 'desc',
icon: {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockMutateAsync).toHaveBeenCalledWith({
body: {
name: 'My snippet',
description: 'desc',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-123' },
body: {
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
expect(result.current.isCreatingSnippet).toBe(false)
})
it('should show error toast when create fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('create failed'))
const { result } = renderHook(() => useCreateSnippet())
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: '',
icon: {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockToastError).toHaveBeenCalledWith('create failed')
expect(result.current.isCreatingSnippet).toBe(false)
})
})
})

View File

@@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react'
import { useInsertSnippet } from '../use-insert-snippet'
const mockFetchQuery = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockToastError = vi.fn()
const mockGetNodes = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
fetchQuery: mockFetchQuery,
}),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
edges: [{ id: 'existing-edge', source: 'old', target: 'old-2' }],
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('../../../hooks', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
useWorkflowHistory: () => ({
saveStateToHistory: mockSaveStateToHistory,
}),
WorkflowHistoryEvent: {
NodePaste: 'NodePaste',
},
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
describe('useInsertSnippet', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetNodes.mockReturnValue([
{
id: 'existing-node',
position: { x: 0, y: 0 },
data: { selected: true },
},
])
})
describe('Insert Flow', () => {
it('should append remapped snippet graph into current workflow graph', async () => {
mockFetchQuery.mockResolvedValue({
graph: {
nodes: [
{
id: 'snippet-node-1',
position: { x: 10, y: 20 },
data: { selected: false, _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }] },
},
{
id: 'snippet-node-2',
parentId: 'snippet-node-1',
position: { x: 30, y: 40 },
data: { selected: false },
},
],
edges: [
{
id: 'edge-1',
source: 'snippet-node-1',
sourceHandle: 'source',
target: 'snippet-node-2',
targetHandle: 'target',
data: {},
},
],
},
})
const { result } = renderHook(() => useInsertSnippet())
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
expect(mockSetNodes).toHaveBeenCalledTimes(1)
expect(mockSetEdges).toHaveBeenCalledTimes(1)
const nextNodes = mockSetNodes.mock.calls[0][0]
expect(nextNodes[0].selected).toBe(false)
expect(nextNodes[0].data.selected).toBe(false)
expect(nextNodes).toHaveLength(3)
expect(nextNodes[1].id).not.toBe('snippet-node-1')
expect(nextNodes[2].parentId).toBe(nextNodes[1].id)
expect(nextNodes[1].data._children[0].nodeId).toBe(nextNodes[2].id)
const nextEdges = mockSetEdges.mock.calls[0][0]
expect(nextEdges).toHaveLength(2)
expect(nextEdges[1].source).toBe(nextNodes[1].id)
expect(nextEdges[1].target).toBe(nextNodes[2].id)
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', {
nodeId: nextNodes[1].id,
})
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should show error toast when fetching snippet workflow fails', async () => {
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
const { result } = renderHook(() => useInsertSnippet())
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockToastError).toHaveBeenCalledWith('insert failed')
})
})
})

View File

@@ -0,0 +1,179 @@
import { useInfiniteScroll } from 'ahooks'
import {
memo,
useDeferredValue,
useMemo,
useRef,
useState,
} from 'react'
import Loading from '@/app/components/base/loading'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@/app/components/base/ui/scroll-area'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import CreateSnippetDialog from '../../create-snippet-dialog'
import SnippetDetailCard from './snippet-detail-card'
import SnippetEmptyState from './snippet-empty-state'
import SnippetListItem from './snippet-list-item'
import { useCreateSnippet } from './use-create-snippet'
import { useInsertSnippet } from './use-insert-snippet'
type SnippetsProps = {
loading?: boolean
searchText: string
}
const LoadingSkeleton = () => {
return (
<div className="relative overflow-hidden">
<div className="p-1">
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key, index) => (
<div
key={key}
className={cn(
'flex items-center gap-1 px-3 py-1 opacity-20',
index === 3 && 'opacity-10',
)}
>
<div className="my-1 h-6 w-6 shrink-0 rounded-lg border-[0.5px] border-effects-icon-border bg-text-quaternary" />
<div className="min-w-0 flex-1 px-1 py-1">
<div className="h-2 w-[200px] rounded-[2px] bg-text-quaternary" />
</div>
</div>
))}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-components-panel-bg-transparent to-background-default-subtle" />
</div>
)
}
const Snippets = ({
loading = false,
searchText,
}: SnippetsProps) => {
const {
createSnippetMutation,
handleCloseCreateSnippetDialog,
handleCreateSnippet,
handleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen,
isCreatingSnippet,
} = useCreateSnippet()
const { handleInsertSnippet } = useInsertSnippet()
const deferredSearchText = useDeferredValue(searchText)
const viewportRef = useRef<HTMLDivElement>(null)
const [hoveredSnippetId, setHoveredSnippetId] = useState<string | null>(null)
const keyword = deferredSearchText.trim() || undefined
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteSnippetList({
page: 1,
limit: 30,
keyword,
is_published: true,
})
const snippets = useMemo(() => {
return (data?.pages ?? []).flatMap(({ data }) => data)
}, [data?.pages])
const isNoMore = hasNextPage === false
useInfiniteScroll(
async () => {
if (!hasNextPage || isFetchingNextPage)
return { list: [] }
await fetchNextPage()
return { list: [] }
},
{
target: viewportRef,
isNoMore: () => isNoMore,
reloadDeps: [isNoMore, isFetchingNextPage, keyword],
},
)
if (loading || isLoading || (isFetching && snippets.length === 0))
return <LoadingSkeleton />
return (
<>
{!snippets.length
? (
<SnippetEmptyState onCreate={handleOpenCreateSnippetDialog} />
)
: (
<ScrollAreaRoot className="relative max-h-[480px] max-w-[500px] overflow-hidden">
<ScrollAreaViewport ref={viewportRef}>
<ScrollAreaContent className="p-1">
{snippets.map((item) => {
const row = (
<SnippetListItem
snippet={item}
isHovered={hoveredSnippetId === item.id}
onClick={() => handleInsertSnippet(item.id)}
onMouseEnter={() => setHoveredSnippetId(item.id)}
onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
/>
)
if (!item.description)
return <div key={item.id}>{row}</div>
return (
<Tooltip key={item.id}>
<TooltipTrigger
delay={0}
render={row}
/>
<TooltipContent
placement="left-start"
variant="plain"
popupClassName="!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>
)}
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippetDialog}
onConfirm={handleCreateSnippet}
/>
</>
)
}
export default memo(Snippets)

View File

@@ -0,0 +1,92 @@
import type { FC } from 'react'
import type { SnippetListItem } from '@/types/snippet'
import { useMemo } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
export type PublishedSnippetListItem = SnippetListItem
type SnippetDetailCardProps = {
snippet: PublishedSnippetListItem
}
const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
snippet,
}) => {
const { author, description, icon_info, name } = snippet
const { data: workflow } = useSnippetPublishedWorkflow(snippet.id)
const blockTypes = useMemo(() => {
const graph = workflow?.graph
if (!graph || typeof graph !== 'object')
return []
const graphRecord = graph as Record<string, unknown>
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
const nodeRecord = node as Record<string, unknown>
if (!nodeRecord.data || typeof nodeRecord.data !== 'object')
return result
const dataRecord = nodeRecord.data as Record<string, unknown>
const blockType = dataRecord.type
if (typeof blockType !== 'string' || !availableBlockTypes.has(blockType as BlockEnum))
return result
const normalizedBlockType = blockType as BlockEnum
if (!result.includes(normalizedBlockType))
result.push(normalizedBlockType)
return result
}, [])
}, [workflow?.graph])
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<AppIcon
size="tiny"
iconType={icon_info.icon_type}
icon={icon_info.icon}
background={icon_info.icon_background}
imageUrl={icon_info.icon_url}
/>
<div className="text-text-primary system-md-medium">{name}</div>
</div>
{!!description && (
<div className="w-[200px] text-text-secondary system-xs-regular">
{description}
</div>
)}
{!!blockTypes.length && (
<div className="flex items-center gap-0.5 pt-1">
{blockTypes.map(blockType => (
<BlockIcon
key={blockType}
type={blockType}
size="sm"
/>
))}
</div>
)}
</div>
{!!author && (
<div className="pt-3 text-text-tertiary system-xs-regular">
{author}
</div>
)}
</div>
)
}
export default SnippetDetailCard

View File

@@ -0,0 +1,31 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
type SnippetEmptyStateProps = {
onCreate: () => void
}
const SnippetEmptyState: FC<SnippetEmptyStateProps> = ({
onCreate,
}) => {
const { t } = useTranslation()
return (
<div className="flex min-h-[480px] 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="text-text-secondary system-sm-regular">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
<Button
variant="secondary-accent"
size="small"
onClick={onCreate}
>
{t('tabs.createSnippet', { ns: 'workflow' })}
</Button>
</div>
)
}
export default SnippetEmptyState

View File

@@ -0,0 +1,51 @@
import type {
ComponentPropsWithoutRef,
Ref,
} from 'react'
import type { PublishedSnippetListItem } from './snippet-detail-card'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
type SnippetListItemProps = {
isHovered: boolean
ref?: Ref<HTMLDivElement>
snippet: PublishedSnippetListItem
} & ComponentPropsWithoutRef<'div'>
const SnippetListItem = ({
isHovered,
snippet,
className,
ref,
...props
}: SnippetListItemProps) => {
return (
<div
ref={ref}
className={cn(
'flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3',
isHovered && 'bg-background-default-hover',
className,
)}
{...props}
>
<AppIcon
size="tiny"
iconType={snippet.icon_info.icon_type}
icon={snippet.icon_info.icon}
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="min-w-0 text-text-secondary system-sm-medium">
{snippet.name}
</div>
{isHovered && snippet.author && (
<div className="ml-auto text-text-tertiary system-xs-regular">
{snippet.author}
</div>
)}
</div>
)
}
export default SnippetListItem

View File

@@ -0,0 +1,71 @@
import type { CreateSnippetDialogPayload } from '../../create-snippet-dialog'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { useCreateSnippetMutation } from '@/service/use-snippets'
export const useCreateSnippet = () => {
const { t } = useTranslation()
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
const handleOpenCreateSnippetDialog = () => {
setIsCreateSnippetDialogOpen(true)
}
const handleCloseCreateSnippetDialog = () => {
setIsCreateSnippetDialogOpen(false)
}
const handleCreateSnippet = async ({
name,
description,
icon,
graph,
}: CreateSnippetDialogPayload) => {
setIsCreatingSnippet(true)
try {
const snippet = await createSnippetMutation.mutateAsync({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
})
await consoleClient.snippets.syncDraftWorkflow({
params: { snippetId: snippet.id },
body: { graph },
})
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
handleCloseCreateSnippetDialog()
push(`/snippets/${snippet.id}/orchestrate`)
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
}
finally {
setIsCreatingSnippet(false)
}
}
return {
createSnippetMutation,
handleCloseCreateSnippetDialog,
handleCreateSnippet,
handleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen,
isCreatingSnippet,
}
}

View File

@@ -0,0 +1,143 @@
import type { Edge, Node } from '../../types'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { toast } from '@/app/components/base/ui/toast'
import { consoleQuery } from '@/service/client'
import { useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks'
const getSnippetGraph = (graph: Record<string, unknown> | undefined) => {
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[] : [],
}
}
const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEdges: Edge[]) => {
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 currentMaxX = currentNodes.length
? 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))
: 0
const offsetX = (currentNodes.length ? currentMaxX + 80 : 80) - minRootX
const offsetY = (currentNodes.length ? currentMinY : 80) - minRootY
snippetNodes.forEach((node, index) => {
let nextId = `${node.id}-${Date.now()}-${index}`
while (existingIds.has(nextId))
nextId = `${nextId}-1`
existingIds.add(nextId)
idMapping.set(node.id, nextId)
})
const nodes = snippetNodes.map((node) => {
const nextParentId = node.parentId ? idMapping.get(node.parentId) : undefined
const isRootNode = !node.parentId
return {
...node,
id: idMapping.get(node.id)!,
parentId: nextParentId,
position: isRootNode
? {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
}
: node.position,
positionAbsolute: 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 => ({
...child,
nodeId: idMapping.get(child.nodeId) ?? child.nodeId,
})),
},
}
})
const edges = snippetEdges.map(edge => ({
...edge,
id: `${idMapping.get(edge.source)}-${edge.sourceHandle}-${idMapping.get(edge.target)}-${edge.targetHandle}`,
source: idMapping.get(edge.source)!,
target: idMapping.get(edge.target)!,
selected: false,
data: edge.data
? {
...edge.data,
_connectedNodeIsSelected: true,
}
: edge.data,
}))
return { nodes, edges }
}
export const useInsertSnippet = () => {
const { t } = useTranslation()
const queryClient = useQueryClient()
const store = useStoreApi()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const handleInsertSnippet = useCallback(async (snippetId: string) => {
try {
const workflow = await queryClient.fetchQuery(consoleQuery.snippets.publishedWorkflow.queryOptions({
input: {
params: { 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)
const clearedNodes = currentNodes.map(node => ({
...node,
selected: false,
data: {
...node.data,
selected: false,
},
}))
setNodes([...clearedNodes, ...remappedGraph.nodes])
setEdges([...edges, ...remappedGraph.edges])
saveStateToHistory(WorkflowHistoryEvent.NodePaste, {
nodeId: remappedGraph.nodes[0]?.id,
})
handleSyncWorkflowDraft()
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
}
}, [handleSyncWorkflowDraft, queryClient, saveStateToHistory, store, t])
return {
handleInsertSnippet,
}
}

View File

@@ -40,6 +40,7 @@ export type TabsProps = {
noTools?: boolean
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
snippetsElem?: React.ReactNode
}
const normalizeToolList = (list: ToolWithProvider[] | undefined, currentBasePath?: string) => {
@@ -173,6 +174,7 @@ const Tabs: FC<TabsProps> = ({
noTools,
forceShowStartContent = false,
allowStartNodeSelection = false,
snippetsElem,
}) => {
const { t } = useTranslation()
const { data: buildInTools } = useAllBuiltInTools()
@@ -289,6 +291,13 @@ const Tabs: FC<TabsProps> = ({
/>
)
}
{
activeTab === TabsEnum.Snippets && snippetsElem && (
<div className="border-t border-divider-subtle">
{snippetsElem}
</div>
)
}
</div>
)
}

View File

@@ -7,6 +7,7 @@ export enum TabsEnum {
Blocks = 'blocks',
Tools = 'tools',
Sources = 'sources',
Snippets = 'snippets',
}
export enum ToolTypeEnum {

View File

@@ -0,0 +1,199 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetCanvasData } from '@/models/snippet'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@/app/components/base/ui/dialog'
import ShortcutsName from './shortcuts-name'
export type CreateSnippetDialogPayload = {
name: string
description: string
icon: AppIconSelection
graph: SnippetCanvasData
}
export type CreateSnippetDialogInitialValue = {
name?: string
description?: string
icon?: AppIconSelection
}
type CreateSnippetDialogProps = {
isOpen: boolean
selectedGraph?: SnippetCanvasData
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
isSubmitting?: boolean
title?: string
confirmText?: string
initialValue?: CreateSnippetDialogInitialValue
}
const defaultIcon: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const defaultGraph: SnippetCanvasData = {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
}
const CreateSnippetDialog: FC<CreateSnippetDialogProps> = ({
isOpen,
selectedGraph,
onClose,
onConfirm,
isSubmitting = false,
title,
confirmText,
initialValue,
}) => {
const { t } = useTranslation()
const [name, setName] = useState(initialValue?.name ?? '')
const [description, setDescription] = useState(initialValue?.description ?? '')
const [icon, setIcon] = useState<AppIconSelection>(initialValue?.icon ?? defaultIcon)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const resetForm = useCallback(() => {
setName('')
setDescription('')
setIcon(defaultIcon)
setShowAppIconPicker(false)
}, [])
const handleClose = useCallback(() => {
resetForm()
onClose()
}, [onClose, resetForm])
const handleConfirm = useCallback(() => {
const trimmedName = name.trim()
const trimmedDescription = description.trim()
if (!trimmedName)
return
const payload = {
name: trimmedName,
description: trimmedDescription,
icon,
graph: selectedGraph ?? defaultGraph,
}
onConfirm(payload)
}, [description, icon, name, onConfirm, selectedGraph])
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (!isOpen)
return
if (isSubmitting)
return
handleConfirm()
})
return (
<>
<Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
<DialogContent className="w-[520px] max-w-[520px] p-0">
<DialogCloseButton />
<div className="px-6 pb-3 pt-6">
<DialogTitle className="text-text-primary title-2xl-semi-bold">
{title || t('snippet.createDialogTitle', { ns: 'workflow' })}
</DialogTitle>
</div>
<div className="space-y-4 px-6 py-2">
<div className="flex items-end gap-3">
<div className="flex-1 pb-0.5">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.nameLabel', { ns: 'workflow' })}
</div>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('snippet.namePlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
autoFocus
/>
</div>
<AppIcon
size="xxl"
className="shrink-0 cursor-pointer"
iconType={icon.type}
icon={icon.type === 'emoji' ? icon.icon : icon.fileId}
background={icon.type === 'emoji' ? icon.background : undefined}
imageUrl={icon.type === 'image' ? icon.url : undefined}
onClick={() => setShowAppIconPicker(true)}
/>
</div>
<div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">
{t('snippet.descriptionLabel', { ns: 'workflow' })}
</div>
<Textarea
className="resize-none"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={t('snippet.descriptionPlaceholder', { ns: 'workflow' }) || ''}
disabled={isSubmitting}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 pb-6 pt-5">
<Button disabled={isSubmitting} onClick={handleClose}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
disabled={!name.trim() || isSubmitting}
loading={isSubmitting}
onClick={handleConfirm}
>
{confirmText || t('snippet.confirm', { ns: 'workflow' })}
<ShortcutsName className="ml-1" keys={['ctrl', 'enter']} bgColor="white" />
</Button>
</div>
</DialogContent>
<DialogPortal>
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[1002] flex -translate-x-1/2 translate-y-[170px] items-center gap-1 text-text-quaternary body-xs-regular">
<span>{t('snippet.shortcuts.press', { ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'enter']} textColor="secondary" />
<span>{t('snippet.shortcuts.toConfirm', { ns: 'workflow' })}</span>
</div>
</DialogPortal>
</Dialog>
{showAppIconPicker && (
<AppIconPicker
className="z-[1100]"
onSelect={(selection) => {
setIcon(selection)
setShowAppIconPicker(false)
}}
onClose={() => setShowAppIconPicker(false)}
/>
)}
</>
)
}
export default CreateSnippetDialog

View File

@@ -1,26 +1,34 @@
import type { Node } from './types'
import type { CreateSnippetDialogPayload } from './create-snippet-dialog'
import type { Edge, Node } from './types'
import type { SnippetCanvasData } from '@/models/snippet'
import { produce } from 'immer'
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
} from '@/app/components/base/ui/context-menu'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import { consoleClient } from '@/service/client'
import { useCreateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import CreateSnippetDialog from './create-snippet-dialog'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
import ShortcutsName from './shortcuts-name'
import { useStore, useWorkflowStore } from './store'
import { BlockEnum, TRIGGER_NODE_TYPES } from './types'
const AlignType = {
Bottom: 'bottom',
@@ -42,37 +50,31 @@ type AlignBounds = {
maxY: number
}
type MenuItem = {
type AlignMenuItem = {
alignType: AlignTypeValue
icon: string
iconClassName?: string
translationKey: string
}
type MenuSection = {
titleKey: string
items: MenuItem[]
type ActionMenuItem = {
action: 'copy' | 'createSnippet' | 'delete' | 'duplicate'
disabled?: boolean
shortcutKeys?: string[]
translationKey: string
}
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' },
],
},
{
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' },
],
},
const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 }
const alignMenuItems: AlignMenuItem[] = [
{ alignType: AlignType.Left, icon: 'i-ri-align-item-left-line', translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: 'i-ri-align-item-horizontal-center-line', translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: 'i-ri-align-item-right-line', translationKey: 'operator.alignRight' },
{ alignType: AlignType.Top, icon: 'i-ri-align-item-top-line', translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: 'i-ri-align-item-vertical-center-line', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: 'i-ri-align-item-bottom-line', translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify-line', translationKey: 'operator.distributeHorizontal' },
{ alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify-line', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
]
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
@@ -220,12 +222,90 @@ const distributeNodes = (
})
}
const getSelectedSnippetGraph = (
nodes: Node[],
edges: Edge[],
selectedNodes: Node[],
): SnippetCanvasData => {
const includedNodeIds = new Set(selectedNodes.map(node => node.id))
let shouldExpand = true
while (shouldExpand) {
shouldExpand = false
nodes.forEach((node) => {
if (!includedNodeIds.has(node.id))
return
if (node.parentId && !includedNodeIds.has(node.parentId)) {
includedNodeIds.add(node.parentId)
shouldExpand = true
}
node.data._children?.forEach((child) => {
if (!includedNodeIds.has(child.nodeId)) {
includedNodeIds.add(child.nodeId)
shouldExpand = true
}
})
})
}
const rootNodes = nodes.filter(node => includedNodeIds.has(node.id) && (!node.parentId || !includedNodeIds.has(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
return {
nodes: nodes
.filter(node => includedNodeIds.has(node.id))
.map((node) => {
const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId)
const nextPosition = isRootNode
? { x: node.position.x - minRootX, y: node.position.y - minRootY }
: node.position
return {
...node,
position: nextPosition,
positionAbsolute: node.positionAbsolute
? (isRootNode
? {
x: node.positionAbsolute.x - minRootX,
y: node.positionAbsolute.y - minRootY,
}
: node.positionAbsolute)
: undefined,
selected: false,
data: {
...node.data,
selected: false,
_children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)),
},
}
}),
edges: edges
.filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target))
.map(edge => ({
...edge,
selected: false,
})),
viewport: DEFAULT_SNIPPET_VIEWPORT,
}
}
const SelectionContextmenu = () => {
const { t } = useTranslation()
const { push } = useRouter()
const createSnippetMutation = useCreateSnippetMutation()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const { handleNodesCopy, handleNodesDelete, handleNodesDuplicate } = useNodesInteractions()
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
const selectionMenu = useStore(s => s.selectionMenu)
const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false)
const [isCreatingSnippet, setIsCreatingSnippet] = useState(false)
const [selectedGraphSnapshot, setSelectedGraphSnapshot] = useState<SnippetCanvasData | undefined>()
// Access React Flow methods
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const selectedNodes = useReactFlowStore(state =>
@@ -253,20 +333,118 @@ const SelectionContextmenu = () => {
handleSelectionContextmenuCancel()
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
const handleCopyNodes = useCallback(() => {
handleNodesCopy()
handleSelectionContextmenuCancel()
}, [handleNodesCopy, handleSelectionContextmenuCancel])
const isAddToSnippetDisabled = useMemo(() => {
return selectedNodes.some(node =>
node.data.type === BlockEnum.Start
|| node.data.type === BlockEnum.End
|| node.data.type === BlockEnum.HumanInput
|| TRIGGER_NODE_TYPES.includes(node.data.type as typeof TRIGGER_NODE_TYPES[number]))
}, [selectedNodes])
const handleDuplicateNodes = useCallback(() => {
handleNodesDuplicate()
handleSelectionContextmenuCancel()
}, [handleNodesDuplicate, handleSelectionContextmenuCancel])
const handleOpenCreateSnippetDialog = useCallback(() => {
if (isAddToSnippetDisabled)
return
const handleDeleteNodes = useCallback(() => {
handleNodesDelete()
const nodes = store.getState().getNodes()
const { edges } = store.getState()
setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes))
setIsCreateSnippetDialogOpen(true)
handleSelectionContextmenuCancel()
}, [handleNodesDelete, handleSelectionContextmenuCancel])
}, [handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
const handleCloseCreateSnippetDialog = useCallback(() => {
setIsCreateSnippetDialogOpen(false)
setSelectedGraphSnapshot(undefined)
}, [])
const handleCreateSnippet = useCallback(async ({
name,
description,
icon,
graph,
}: CreateSnippetDialogPayload) => {
setIsCreatingSnippet(true)
try {
const snippet = await createSnippetMutation.mutateAsync({
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
})
await consoleClient.snippets.syncDraftWorkflow({
params: { snippetId: snippet.id },
body: { graph },
})
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
handleCloseCreateSnippetDialog()
push(`/snippets/${snippet.id}/orchestrate`)
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('createFailed', { ns: 'snippet' }))
}
finally {
setIsCreatingSnippet(false)
}
}, [createSnippetMutation, handleCloseCreateSnippetDialog, push, t])
const menuActions = useMemo<ActionMenuItem[]>(() => [
{
action: 'createSnippet',
disabled: isAddToSnippetDisabled,
translationKey: 'snippet.addToSnippet',
},
{
action: 'copy',
shortcutKeys: ['ctrl', 'c'],
translationKey: 'common.copy',
},
{
action: 'duplicate',
shortcutKeys: ['ctrl', 'd'],
translationKey: 'common.duplicate',
},
{
action: 'delete',
shortcutKeys: ['del'],
translationKey: 'operation.delete',
},
], [isAddToSnippetDisabled])
const getActionLabel = useCallback((translationKey: string) => {
if (translationKey === 'operation.delete')
return t(translationKey, { ns: 'common', defaultValue: translationKey })
return t(translationKey, { ns: 'workflow', defaultValue: translationKey })
}, [t])
const handleMenuAction = useCallback((action: ActionMenuItem['action']) => {
switch (action) {
case 'createSnippet':
handleOpenCreateSnippetDialog()
return
case 'copy':
handleSelectionContextmenuCancel()
handleNodesCopy()
return
case 'duplicate':
handleSelectionContextmenuCancel()
handleNodesDuplicate()
return
case 'delete':
handleSelectionContextmenuCancel()
handleNodesDelete()
}
}, [handleNodesCopy, handleNodesDelete, handleNodesDuplicate, handleOpenCreateSnippetDialog, handleSelectionContextmenuCancel])
const handleAlignNodes = useCallback((alignType: AlignTypeValue) => {
if (getNodesReadOnly() || selectedNodes.length <= 1) {
@@ -331,73 +509,74 @@ const SelectionContextmenu = () => {
}
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel])
if (!selectionMenu)
if ((!selectionMenu || !anchor) && !isCreateSnippetDialogOpen)
return null
return (
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenuContent
popupClassName="w-[240px]"
positionerProps={anchor ? { anchor } : undefined}
<div data-testid="selection-contextmenu">
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
data-testid="selection-contextmenu-item-copy"
onClick={handleCopyNodes}
>
<span>{t('common.copy', { defaultValue: 'common.copy', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'c']} />
</ContextMenuItem>
<ContextMenuItem
className="justify-between px-3 text-text-secondary"
data-testid="selection-contextmenu-item-duplicate"
onClick={handleDuplicateNodes}
>
<span>{t('common.duplicate', { defaultValue: 'common.duplicate', ns: 'workflow' })}</span>
<ShortcutsName keys={['ctrl', 'd']} />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem
className="justify-between px-3 text-text-secondary data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive"
data-testid="selection-contextmenu-item-delete"
onClick={handleDeleteNodes}
>
<span>{t('operation.delete', { defaultValue: 'operation.delete', ns: 'common' })}</span>
<ShortcutsName keys={['del']} />
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
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' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
<ContextMenuContent
positionerProps={anchor ? { anchor } : undefined}
popupClassName="w-[240px] py-0"
>
<div className="p-1">
{menuActions.map(item => (
<ContextMenuItem
key={item.action}
data-testid={`selection-contextmenu-item-${item.action}`}
disabled={item.disabled}
className={cn(
'mx-0 h-8 justify-between gap-3 rounded-lg px-2 text-[14px] font-normal leading-5 text-text-secondary',
item.action === 'delete' && 'data-[highlighted]:bg-state-destructive-hover data-[highlighted]:text-text-destructive',
)}
onClick={() => handleMenuAction(item.action)}
>
<span>{getActionLabel(item.translationKey)}</span>
{item.shortcutKeys && (
<ShortcutsName
keys={item.shortcutKeys}
textColor="secondary"
/>
)}
</ContextMenuItem>
))}
</div>
<ContextMenuSeparator className="my-0" />
<div className="p-1.5">
<div className="flex items-center">
{alignMenuItems.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
aria-label={t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
className="mx-0 h-8 w-8 justify-center rounded-md px-0 text-text-tertiary data-[highlighted]:bg-state-base-hover data-[highlighted]:text-text-secondary"
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
</ContextMenuItem>
)
})}
</div>
</div>
</ContextMenuContent>
</ContextMenu>
{isCreateSnippetDialogOpen && (
<CreateSnippetDialog
isOpen={isCreateSnippetDialogOpen}
selectedGraph={selectedGraphSnapshot}
isSubmitting={isCreatingSnippet || createSnippetMutation.isPending}
onClose={handleCloseCreateSnippetDialog}
onConfirm={handleCreateSnippet}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,305 @@
import type {
EvaluationConfig,
EvaluationConfigData,
EvaluationFileInfo,
EvaluationLogsResponse,
EvaluationMetricsListResponse,
EvaluationMetricsMapResponse,
EvaluationNodeInfoRequest,
EvaluationNodeInfoResponse,
EvaluationRun,
EvaluationRunDetailResponse,
EvaluationRunRequest,
EvaluationTargetType,
EvaluationVersionDetailResponse,
} from '@/types/evaluation'
import { type } from '@orpc/contract'
import { base } from '../base'
export const datasetEvaluationTemplateDownloadContract = base
.route({
path: '/datasets/{datasetId}/evaluation/template/download',
method: 'POST',
})
.input(type<{
params: {
datasetId: string
}
}>())
.output(type<unknown>())
export const datasetEvaluationConfigContract = base
.route({
path: '/datasets/{datasetId}/evaluation',
method: 'GET',
})
.input(type<{
params: {
datasetId: string
}
}>())
.output(type<EvaluationConfig>())
export const saveDatasetEvaluationConfigContract = base
.route({
path: '/datasets/{datasetId}/evaluation',
method: 'PUT',
})
.input(type<{
params: {
datasetId: string
}
body: EvaluationConfigData
}>())
.output(type<EvaluationConfig>())
export const startDatasetEvaluationRunContract = base
.route({
path: '/datasets/{datasetId}/evaluation/run',
method: 'POST',
})
.input(type<{
params: {
datasetId: string
}
body: EvaluationRunRequest
}>())
.output(type<EvaluationRun>())
export const datasetEvaluationLogsContract = base
.route({
path: '/datasets/{datasetId}/evaluation/logs',
method: 'GET',
})
.input(type<{
params: {
datasetId: string
}
query: {
page?: number
page_size?: number
}
}>())
.output(type<EvaluationLogsResponse>())
export const datasetEvaluationRunDetailContract = base
.route({
path: '/datasets/{datasetId}/evaluation/runs/{runId}',
method: 'GET',
})
.input(type<{
params: {
datasetId: string
runId: string
}
query: {
page?: number
page_size?: number
}
}>())
.output(type<EvaluationRunDetailResponse>())
export const cancelDatasetEvaluationRunContract = base
.route({
path: '/datasets/{datasetId}/evaluation/runs/{runId}/cancel',
method: 'POST',
})
.input(type<{
params: {
datasetId: string
runId: string
}
}>())
.output(type<EvaluationRun>())
export const datasetEvaluationMetricsContract = base
.route({
path: '/datasets/{datasetId}/evaluation/metrics',
method: 'GET',
})
.input(type<{
params: {
datasetId: string
}
}>())
.output(type<EvaluationMetricsListResponse>())
export const datasetEvaluationFileContract = base
.route({
path: '/datasets/{datasetId}/evaluation/files/{fileId}',
method: 'GET',
})
.input(type<{
params: {
datasetId: string
fileId: string
}
}>())
.output(type<EvaluationFileInfo>())
export const evaluationTemplateDownloadContract = base
.route({
path: '/{targetType}/{targetId}/dataset-template/download',
method: 'POST',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
}>())
.output(type<unknown>())
export const evaluationConfigContract = base
.route({
path: '/{targetType}/{targetId}/evaluation',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
}>())
.output(type<EvaluationConfig>())
export const saveEvaluationConfigContract = base
.route({
path: '/{targetType}/{targetId}/evaluation',
method: 'PUT',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
body: EvaluationConfigData
}>())
.output(type<EvaluationConfig>())
export const evaluationLogsContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/logs',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
query: {
page?: number
page_size?: number
}
}>())
.output(type<EvaluationLogsResponse>())
export const startEvaluationRunContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/run',
method: 'POST',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
body: EvaluationRunRequest
}>())
.output(type<EvaluationRun>())
export const evaluationRunDetailContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/runs/{runId}',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
runId: string
}
query: {
page?: number
page_size?: number
}
}>())
.output(type<EvaluationRunDetailResponse>())
export const cancelEvaluationRunContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/runs/{runId}/cancel',
method: 'POST',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
runId: string
}
}>())
.output(type<EvaluationRun>())
export const evaluationMetricsContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/metrics',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
}>())
.output(type<EvaluationMetricsMapResponse>())
export const evaluationNodeInfoContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/node-info',
method: 'POST',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
body: EvaluationNodeInfoRequest
}>())
.output(type<EvaluationNodeInfoResponse>())
export const availableEvaluationMetricsContract = base
.route({
path: '/evaluation/available-metrics',
method: 'GET',
})
.output(type<EvaluationMetricsListResponse>())
export const evaluationFileContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/files/{fileId}',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
fileId: string
}
}>())
.output(type<EvaluationFileInfo>())
export const evaluationVersionDetailContract = base
.route({
path: '/{targetType}/{targetId}/evaluation/version',
method: 'GET',
})
.input(type<{
params: {
targetType: EvaluationTargetType
targetId: string
}
query: {
version: string
}
}>())
.output(type<EvaluationVersionDetailResponse>())

View File

@@ -0,0 +1,342 @@
import type {
CreateSnippetPayload,
IncrementSnippetUseCountResponse,
PublishSnippetWorkflowResponse,
Snippet,
SnippetDraftConfig,
SnippetDraftNodeRunPayload,
SnippetDraftRunPayload,
SnippetDraftSyncPayload,
SnippetDraftSyncResponse,
SnippetImportPayload,
SnippetIterationNodeRunPayload,
SnippetListResponse,
SnippetLoopNodeRunPayload,
SnippetWorkflow,
UpdateSnippetPayload,
WorkflowNodeExecution,
WorkflowNodeExecutionListResponse,
WorkflowRunDetail,
WorkflowRunPagination,
} from '@/types/snippet'
import { type } from '@orpc/contract'
import { base } from '../base'
export const listCustomizedSnippetsContract = base
.route({
path: '/workspaces/current/customized-snippets',
method: 'GET',
})
.input(type<{
query: {
page: number
limit: number
keyword?: string
is_published?: boolean
}
}>())
.output(type<SnippetListResponse>())
export const createCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets',
method: 'POST',
})
.input(type<{
body: CreateSnippetPayload
}>())
.output(type<Snippet>())
export const getCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<Snippet>())
export const updateCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}',
method: 'PATCH',
})
.input(type<{
params: {
snippetId: string
}
body: UpdateSnippetPayload
}>())
.output(type<Snippet>())
export const deleteCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}',
method: 'DELETE',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<unknown>())
export const exportCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}/export',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
query: {
include_secret?: 'true' | 'false'
}
}>())
.output(type<string>())
export const importCustomizedSnippetContract = base
.route({
path: '/workspaces/current/customized-snippets/imports',
method: 'POST',
})
.input(type<{
body: SnippetImportPayload
}>())
.output(type<unknown>())
export const confirmSnippetImportContract = base
.route({
path: '/workspaces/current/customized-snippets/imports/{importId}/confirm',
method: 'POST',
})
.input(type<{
params: {
importId: string
}
}>())
.output(type<unknown>())
export const checkSnippetDependenciesContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}/check-dependencies',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<unknown>())
export const incrementSnippetUseCountContract = base
.route({
path: '/workspaces/current/customized-snippets/{snippetId}/use-count/increment',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<IncrementSnippetUseCountResponse>())
export const getSnippetDraftWorkflowContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<SnippetWorkflow>())
export const syncSnippetDraftWorkflowContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
}
body: SnippetDraftSyncPayload
}>())
.output(type<SnippetDraftSyncResponse>())
export const getSnippetDraftConfigContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/config',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<SnippetDraftConfig>())
export const getSnippetPublishedWorkflowContract = base
.route({
path: '/snippets/{snippetId}/workflows/publish',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<SnippetWorkflow>())
export const publishSnippetWorkflowContract = base
.route({
path: '/snippets/{snippetId}/workflows/publish',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<PublishSnippetWorkflowResponse>())
export const getSnippetDefaultBlockConfigsContract = base
.route({
path: '/snippets/{snippetId}/workflows/default-workflow-block-configs',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
}>())
.output(type<unknown>())
export const listSnippetWorkflowRunsContract = base
.route({
path: '/snippets/{snippetId}/workflow-runs',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
}
query: {
last_id?: string
limit?: number
}
}>())
.output(type<WorkflowRunPagination>())
export const getSnippetWorkflowRunDetailContract = base
.route({
path: '/snippets/{snippetId}/workflow-runs/{runId}',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
runId: string
}
}>())
.output(type<WorkflowRunDetail>())
export const listSnippetWorkflowRunNodeExecutionsContract = base
.route({
path: '/snippets/{snippetId}/workflow-runs/{runId}/node-executions',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
runId: string
}
}>())
.output(type<WorkflowNodeExecutionListResponse>())
export const runSnippetDraftNodeContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/nodes/{nodeId}/run',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
nodeId: string
}
body: SnippetDraftNodeRunPayload
}>())
.output(type<WorkflowNodeExecution>())
export const getSnippetDraftNodeLastRunContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/nodes/{nodeId}/last-run',
method: 'GET',
})
.input(type<{
params: {
snippetId: string
nodeId: string
}
}>())
.output(type<WorkflowNodeExecution>())
export const runSnippetDraftIterationNodeContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/iteration/nodes/{nodeId}/run',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
nodeId: string
}
body: SnippetIterationNodeRunPayload
}>())
.output(type<unknown>())
export const runSnippetDraftLoopNodeContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/loop/nodes/{nodeId}/run',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
nodeId: string
}
body: SnippetLoopNodeRunPayload
}>())
.output(type<unknown>())
export const runSnippetDraftWorkflowContract = base
.route({
path: '/snippets/{snippetId}/workflows/draft/run',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
}
body: SnippetDraftRunPayload
}>())
.output(type<unknown>())
export const stopSnippetWorkflowTaskContract = base
.route({
path: '/snippets/{snippetId}/workflow-runs/tasks/{taskId}/stop',
method: 'POST',
})
.input(type<{
params: {
snippetId: string
taskId: string
}
}>())
.output(type<unknown>())

Some files were not shown because too many files have changed in this diff Show More