mirror of
https://github.com/langgenius/dify.git
synced 2026-05-11 23:18:39 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
364c0eb6e2
@ -20,10 +20,13 @@ class TenantUserPayload(BaseModel):
|
||||
|
||||
def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
"""
|
||||
Get current user
|
||||
Get current user.
|
||||
|
||||
NOTE: user_id is not trusted, it could be maliciously set to any value.
|
||||
As a result, it could only be considered as an end user id.
|
||||
As a result, it could only be considered as an end user id. Even when a
|
||||
concrete end-user ID is supplied, lookups must stay tenant-scoped so one
|
||||
tenant cannot bind another tenant's user record into the plugin request
|
||||
context.
|
||||
"""
|
||||
if not user_id:
|
||||
user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID
|
||||
@ -42,7 +45,14 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser:
|
||||
.limit(1)
|
||||
)
|
||||
else:
|
||||
user_model = session.get(EndUser, user_id)
|
||||
user_model = session.scalar(
|
||||
select(EndUser)
|
||||
.where(
|
||||
EndUser.id == user_id,
|
||||
EndUser.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not user_model:
|
||||
user_model = EndUser(
|
||||
|
||||
@ -41,17 +41,22 @@ class TestTenantUserPayload:
|
||||
class TestGetUser:
|
||||
"""Test get_user function"""
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.select")
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_return_existing_user_by_id(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask):
|
||||
def test_should_return_existing_user_by_id(
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask
|
||||
):
|
||||
"""Test returning existing user when found by ID"""
|
||||
# Arrange
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user123"
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.return_value = mock_user
|
||||
mock_session.scalar.return_value = mock_user
|
||||
mock_query = MagicMock()
|
||||
mock_select.return_value.where.return_value.limit.return_value = mock_query
|
||||
|
||||
# Act
|
||||
with app.app_context():
|
||||
@ -59,13 +64,45 @@ class TestGetUser:
|
||||
|
||||
# Assert
|
||||
assert result == mock_user
|
||||
mock_session.get.assert_called_once()
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.select")
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_not_resolve_non_anonymous_users_across_tenants(
|
||||
self,
|
||||
mock_db,
|
||||
mock_sessionmaker,
|
||||
mock_enduser_class,
|
||||
mock_select,
|
||||
app: Flask,
|
||||
):
|
||||
"""Test that explicit user IDs remain scoped to the current tenant."""
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session.scalar.return_value = None
|
||||
mock_new_user = MagicMock()
|
||||
mock_new_user.tenant_id = "tenant-current"
|
||||
mock_enduser_class.return_value = mock_new_user
|
||||
|
||||
# Act
|
||||
with app.app_context():
|
||||
result = get_user("tenant-current", "foreign-user-id")
|
||||
|
||||
# Assert
|
||||
assert result == mock_new_user
|
||||
mock_session.get.assert_not_called()
|
||||
mock_session.scalar.assert_called_once()
|
||||
mock_session.add.assert_called_once_with(mock_new_user)
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.select")
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_return_existing_anonymous_user_by_session_id(
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask
|
||||
):
|
||||
"""Test returning existing anonymous user by session_id"""
|
||||
# Arrange
|
||||
@ -73,8 +110,9 @@ class TestGetUser:
|
||||
mock_user.session_id = "anonymous_session"
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
# non-anonymous path uses session.get(); anonymous uses session.scalar()
|
||||
mock_session.get.return_value = mock_user
|
||||
mock_session.scalar.return_value = mock_user
|
||||
mock_query = MagicMock()
|
||||
mock_select.return_value.where.return_value.limit.return_value = mock_query
|
||||
|
||||
# Act
|
||||
with app.app_context():
|
||||
@ -83,17 +121,22 @@ class TestGetUser:
|
||||
# Assert
|
||||
assert result == mock_user
|
||||
|
||||
@patch("controllers.inner_api.plugin.wraps.select")
|
||||
@patch("controllers.inner_api.plugin.wraps.EndUser")
|
||||
@patch("controllers.inner_api.plugin.wraps.sessionmaker")
|
||||
@patch("controllers.inner_api.plugin.wraps.db")
|
||||
def test_should_create_new_user_when_not_found(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask):
|
||||
def test_should_create_new_user_when_not_found(
|
||||
self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask
|
||||
):
|
||||
"""Test creating new user when not found in database"""
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.return_value = None
|
||||
mock_session.scalar.return_value = None
|
||||
mock_new_user = MagicMock()
|
||||
mock_enduser_class.return_value = mock_new_user
|
||||
mock_query = MagicMock()
|
||||
mock_select.return_value.where.return_value.limit.return_value = mock_query
|
||||
|
||||
# Act
|
||||
with app.app_context():
|
||||
@ -134,7 +177,7 @@ class TestGetUser:
|
||||
# Arrange
|
||||
mock_session = MagicMock()
|
||||
mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session
|
||||
mock_session.get.side_effect = Exception("Database error")
|
||||
mock_session.scalar.side_effect = Exception("Database error")
|
||||
|
||||
# Act & Assert
|
||||
with app.app_context():
|
||||
|
||||
11
e2e/features/apps/create-agent-app.feature
Normal file
11
e2e/features/apps/create-agent-app.feature
Normal file
@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Agent app
|
||||
Scenario: Create a new Agent app and redirect to the configuration page
|
||||
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 expand the beginner app types
|
||||
And I select the "Agent" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app configuration page
|
||||
10
e2e/features/apps/create-chatflow-app.feature
Normal file
10
e2e/features/apps/create-chatflow-app.feature
Normal file
@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Chatflow app
|
||||
Scenario: Create a new Chatflow app and redirect to the workflow 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 select the "Chatflow" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the workflow editor
|
||||
11
e2e/features/apps/create-text-generator-app.feature
Normal file
11
e2e/features/apps/create-text-generator-app.feature
Normal file
@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Text Generator app
|
||||
Scenario: Create a new Text Generator app and redirect to the configuration page
|
||||
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 expand the beginner app types
|
||||
And I select the "Text Generator" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app configuration page
|
||||
11
e2e/features/apps/delete-app.feature
Normal file
11
e2e/features/apps/delete-app.feature
Normal file
@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Delete app
|
||||
Scenario: Delete an existing app from the apps console
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Delete" in the app options menu
|
||||
And I type the app name in the deletion confirmation
|
||||
And I confirm the deletion
|
||||
Then the app should no longer appear in the apps console
|
||||
10
e2e/features/apps/duplicate-app.feature
Normal file
10
e2e/features/apps/duplicate-app.feature
Normal file
@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Duplicate app
|
||||
Scenario: Duplicate an existing app and open the copy in the editor
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Duplicate" in the app options menu
|
||||
And I confirm the app duplication
|
||||
Then I should land on the app editor
|
||||
9
e2e/features/apps/export-app.feature
Normal file
9
e2e/features/apps/export-app.feature
Normal file
@ -0,0 +1,9 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Export app DSL
|
||||
Scenario: Export the DSL file for an existing app
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E completion app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Export DSL" in the app options menu
|
||||
Then a YAML file named after the app should be downloaded
|
||||
10
e2e/features/apps/switch-app-mode.feature
Normal file
10
e2e/features/apps/switch-app-mode.feature
Normal file
@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Switch app mode
|
||||
Scenario: Switch a Completion app to Workflow Orchestrate
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E completion app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Switch to Workflow Orchestrate" in the app options menu
|
||||
And I confirm the app switch
|
||||
Then I should land on the switched app
|
||||
@ -11,7 +11,7 @@ When('I start creating a blank app', async function (this: DifyWorld) {
|
||||
|
||||
When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||
const appName = `E2E App ${Date.now()}`
|
||||
|
||||
this.lastCreatedAppName = appName
|
||||
await this.getPage().getByPlaceholder('Give your app a name').fill(appName)
|
||||
})
|
||||
|
||||
@ -26,10 +26,15 @@ When('I confirm app creation', async function (this: DifyWorld) {
|
||||
|
||||
When('I select the {string} app type', async function (this: DifyWorld, appType: string) {
|
||||
const dialog = this.getPage().getByRole('dialog')
|
||||
const appTypeTitle = dialog.getByText(appType, { exact: true })
|
||||
// The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders
|
||||
// <h4>Chatflow</h4> alongside the card's <div>Chatflow</div>.
|
||||
// locator('div').getByText(...) would still match the <h4> because getByText
|
||||
// searches inside each div for any descendant. Use :text-is() instead, which
|
||||
// targets only <div> elements whose own normalised text equals appType exactly.
|
||||
const appTypeCard = dialog.locator(`div:text-is("${appType}")`)
|
||||
|
||||
await expect(appTypeTitle).toBeVisible()
|
||||
await appTypeTitle.click()
|
||||
await expect(appTypeCard).toBeVisible()
|
||||
await appTypeCard.click()
|
||||
})
|
||||
|
||||
When('I expand the beginner app types', async function (this: DifyWorld) {
|
||||
|
||||
35
e2e/features/step-definitions/apps/delete-app.steps.ts
Normal file
35
e2e/features/step-definitions/apps/delete-app.steps.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
When('I type the app name in the deletion confirmation', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
const page = this.getPage()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByPlaceholder('Enter app name…').fill(appName)
|
||||
})
|
||||
|
||||
When('I confirm the deletion', async function (this: DifyWorld) {
|
||||
const dialog = this.getPage().getByRole('alertdialog')
|
||||
await dialog.getByRole('button', { name: 'Confirm' }).click()
|
||||
})
|
||||
|
||||
Then('the app should no longer appear in the apps console', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
await expect(this.getPage().getByTitle(appName)).not.toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
36
e2e/features/step-definitions/apps/duplicate-app.steps.ts
Normal file
36
e2e/features/step-definitions/apps/duplicate-app.steps.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, When } from '@cucumber/cucumber'
|
||||
import { createTestApp } from '../../../support/api'
|
||||
|
||||
Given('there is an existing E2E app available for testing', async function (this: DifyWorld) {
|
||||
const name = `E2E Test App ${Date.now()}`
|
||||
const app = await createTestApp(name, 'completion')
|
||||
this.lastCreatedAppName = app.name
|
||||
this.createdAppIds.push(app.id)
|
||||
})
|
||||
|
||||
When('I open the options menu for the last created E2E app', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName)
|
||||
throw new Error('No app name stored. Run "I enter a unique E2E app name" first.')
|
||||
|
||||
const page = this.getPage()
|
||||
// Scope to the specific card: the card root is the innermost div that contains
|
||||
// both the unique app name text and a More button (they are in separate branches,
|
||||
// so no child div satisfies both). .last() picks the deepest match in DOM order.
|
||||
const appCard = page
|
||||
.locator('div')
|
||||
.filter({ has: page.getByText(appName, { exact: true }) })
|
||||
.filter({ has: page.getByRole('button', { name: 'More' }) })
|
||||
.last()
|
||||
await appCard.hover()
|
||||
await appCard.getByRole('button', { name: 'More' }).click()
|
||||
})
|
||||
|
||||
When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) {
|
||||
await this.getPage().getByRole('menuitem', { name: label }).click()
|
||||
})
|
||||
|
||||
When('I confirm the app duplication', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Duplicate' }).click()
|
||||
})
|
||||
19
e2e/features/step-definitions/apps/export-app.steps.ts
Normal file
19
e2e/features/step-definitions/apps/export-app.steps.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
Then('a YAML file named after the app should be downloaded', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
// The export triggers an async API call before the blob download fires.
|
||||
// Poll until the download event is captured by the page listener in DifyWorld.
|
||||
await expect.poll(() => this.capturedDownloads.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
|
||||
const download = this.capturedDownloads.at(-1)!
|
||||
expect(download.suggestedFilename()).toBe(`${appName}.yml`)
|
||||
})
|
||||
28
e2e/features/step-definitions/apps/switch-app-mode.steps.ts
Normal file
28
e2e/features/step-definitions/apps/switch-app-mode.steps.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createTestApp } from '../../../support/api'
|
||||
|
||||
Given(
|
||||
'there is an existing E2E completion app available for testing',
|
||||
async function (this: DifyWorld) {
|
||||
const name = `E2E Test App ${Date.now()}`
|
||||
const app = await createTestApp(name, 'completion')
|
||||
this.lastCreatedAppName = app.name
|
||||
this.createdAppIds.push(app.id)
|
||||
},
|
||||
)
|
||||
|
||||
When('I confirm the app switch', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Start switch' }).click()
|
||||
})
|
||||
|
||||
Then('I should land on the switched app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await expect(page).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/, { timeout: 15_000 })
|
||||
|
||||
// Capture the new app's ID so the After hook can clean it up
|
||||
const match = page.url().match(/\/app\/([^/]+)\/workflow/)
|
||||
if (match?.[1])
|
||||
this.createdAppIds.push(match[1])
|
||||
})
|
||||
@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
|
||||
import { chromium } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
|
||||
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
@ -88,6 +89,8 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||
)
|
||||
|
||||
for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {})
|
||||
|
||||
await this.closeSession()
|
||||
})
|
||||
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import type { IWorldOptions } from '@cucumber/cucumber'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Download, Page } from '@playwright/test'
|
||||
import type { AuthSessionMetadata } from '../../fixtures/auth'
|
||||
import { setWorldConstructor, World } from '@cucumber/cucumber'
|
||||
import {
|
||||
|
||||
authStatePath,
|
||||
readAuthSessionMetadata,
|
||||
} from '../../fixtures/auth'
|
||||
import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth'
|
||||
import { baseURL, defaultLocale } from '../../test-env'
|
||||
|
||||
export class DifyWorld extends World {
|
||||
@ -16,6 +12,9 @@ export class DifyWorld extends World {
|
||||
pageErrors: string[] = []
|
||||
scenarioStartedAt: number | undefined
|
||||
session: AuthSessionMetadata | undefined
|
||||
lastCreatedAppName: string | undefined
|
||||
createdAppIds: string[] = []
|
||||
capturedDownloads: Download[] = []
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
@ -25,6 +24,9 @@ export class DifyWorld extends World {
|
||||
resetScenarioState() {
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
this.lastCreatedAppName = undefined
|
||||
this.createdAppIds = []
|
||||
this.capturedDownloads = []
|
||||
}
|
||||
|
||||
async startSession(browser: Browser, authenticated: boolean) {
|
||||
@ -45,6 +47,9 @@ export class DifyWorld extends World {
|
||||
this.page.on('pageerror', (error) => {
|
||||
this.pageErrors.push(error.message)
|
||||
})
|
||||
this.page.on('download', (dl) => {
|
||||
this.capturedDownloads.push(dl)
|
||||
})
|
||||
}
|
||||
|
||||
async startAuthenticatedSession(browser: Browser) {
|
||||
|
||||
54
e2e/support/api.ts
Normal file
54
e2e/support/api.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { request } from '@playwright/test'
|
||||
import { authStatePath } from '../fixtures/auth'
|
||||
import { apiURL } from '../test-env'
|
||||
|
||||
type StorageState = {
|
||||
cookies: Array<{ name: string, value: string }>
|
||||
}
|
||||
|
||||
async function createApiContext() {
|
||||
const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState
|
||||
const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? ''
|
||||
|
||||
return request.newContext({
|
||||
baseURL: apiURL,
|
||||
extraHTTPHeaders: { 'X-CSRF-Token': csrfToken },
|
||||
storageState: authStatePath,
|
||||
})
|
||||
}
|
||||
|
||||
export type AppSeed = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function createTestApp(name: string, mode = 'workflow'): Promise<AppSeed> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post('/console/api/apps', {
|
||||
data: {
|
||||
name,
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
},
|
||||
})
|
||||
const body = (await response.json()) as AppSeed
|
||||
return body
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTestApp(id: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.delete(`/console/api/apps/${id}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
})
|
||||
export default defineConfig({})
|
||||
|
||||
@ -218,36 +218,20 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/app-info/app-operations.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/app-sidebar-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/basic.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/dataset-info/dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -338,11 +322,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/publish-with-multiple-model.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/version-info-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -575,11 +554,6 @@
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -802,9 +776,6 @@
|
||||
"web/app/components/base/action-button/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/agent-log-modal/detail.tsx": {
|
||||
@ -2594,11 +2565,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3022,9 +2988,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/item-operation/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
@ -3168,11 +3131,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/operator.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/data-source-page-new/types.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -3196,11 +3154,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/operation/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3490,11 +3443,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/sort-dropdown/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -3851,9 +3799,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
@ -3868,11 +3813,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/plugin-tasks/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/readme-panel/index.tsx": {
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
@ -4091,9 +4031,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/share/text-generation/menu-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4175,11 +4112,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/detail/operation-dropdown.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/detail/tool-item.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4349,9 +4281,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/market-place-plugin/action.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4467,9 +4396,6 @@
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
}
|
||||
@ -4753,11 +4679,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/node-control.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -4773,11 +4694,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/prompt/editor.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5001,11 +4917,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/assigner/components/operation-selector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/assigner/default.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@ -6010,11 +5921,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/toolbar/operator.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/note-node/note-editor/utils.ts": {
|
||||
"regexp/no-useless-quantifier": {
|
||||
"count": 1
|
||||
@ -6030,11 +5936,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/more-actions.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/operator/tip-popup.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -6043,9 +5944,6 @@
|
||||
"web/app/components/workflow/operator/zoom-in-out.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/chat-record/index.tsx": {
|
||||
@ -6141,11 +6039,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/version-history-panel/context-menu/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -6166,11 +6059,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/run/agent-log/agent-log-nav-more.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/run/agent-log/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 2
|
||||
@ -6450,9 +6338,6 @@
|
||||
"web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/expire-notice-modal.tsx": {
|
||||
|
||||
@ -194,7 +194,7 @@ describe('App Sidebar Dataset Info Flow', () => {
|
||||
openDropdown()
|
||||
fireEvent.click(await screen.findByText('common.operation.edit'))
|
||||
|
||||
expect(screen.getByTestId('rename-dataset-modal')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('rename-dataset-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'rename-success' }))
|
||||
|
||||
|
||||
@ -181,7 +181,7 @@ describe('App Sidebar Shell Flow', () => {
|
||||
expect(mockSetAppSidebarExpand).toHaveBeenCalledWith('collapse')
|
||||
})
|
||||
|
||||
it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', () => {
|
||||
it('switches to the workflow fullscreen dropdown shell and opens its navigation menu', async () => {
|
||||
mockPathname = '/app/app-1/workflow'
|
||||
mockSelectedSegment = 'workflow'
|
||||
localStorage.setItem('workflow-canvas-maximize', 'true')
|
||||
@ -190,9 +190,9 @@ describe('App Sidebar Shell Flow', () => {
|
||||
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.more' }))
|
||||
|
||||
expect(screen.getByText('Demo App')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Demo App')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Overview/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Logs/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -19,17 +19,40 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-trigger"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../base/app-icon', () => ({
|
||||
default: ({ size, icon }: { size: string, icon: string }) => (
|
||||
@ -128,11 +151,11 @@ describe('AppSidebarDropdown', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AppSidebarDropdown navigation={navigation} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('dropdown-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
const portal = screen.getByTestId('portal-elem')
|
||||
expect(portal).toHaveAttribute('data-open', 'true')
|
||||
const dropdown = screen.getByTestId('dropdown-menu')
|
||||
expect(dropdown).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render divider between app info and navigation', () => {
|
||||
|
||||
@ -21,17 +21,40 @@ vi.mock('@/hooks/use-knowledge', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-trigger"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div data-testid="dropdown-content">{children}</div>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../base/app-icon', () => ({
|
||||
default: ({ size, icon }: { size: string, icon: string }) => (
|
||||
@ -173,10 +196,10 @@ describe('DatasetSidebarDropdown', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DatasetSidebarDropdown navigation={navigation} />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
const trigger = screen.getByTestId('dropdown-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'true')
|
||||
expect(screen.getByTestId('dropdown-menu')).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render divider', () => {
|
||||
|
||||
@ -30,17 +30,67 @@ vi.mock('../../../base/ui/button', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<div data-testid="portal-content" className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../../../base/ui/dropdown-menu', () => {
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLElement>
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
|
||||
|
||||
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return <div data-testid="dropdown-content" className={popupClassName}>{children}</div>
|
||||
},
|
||||
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuSeparator: () => <hr data-testid="dropdown-separator" />,
|
||||
}
|
||||
})
|
||||
|
||||
const createOperation = (id: string, title: string, type?: 'divider'): Operation => ({
|
||||
id,
|
||||
@ -169,7 +219,7 @@ describe('AppOperations', () => {
|
||||
|
||||
render(<AppOperations gap={4} primaryOperations={ops} secondaryOperations={secondary} />)
|
||||
|
||||
const trigger = screen.queryByTestId('portal-trigger')
|
||||
const trigger = screen.queryByTestId('dropdown-trigger')
|
||||
if (trigger)
|
||||
await user.click(trigger)
|
||||
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import type { JSX } from 'react'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cloneElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../base/ui/dropdown-menu'
|
||||
|
||||
export type Operation = {
|
||||
id: string
|
||||
@ -33,9 +39,6 @@ const AppOperations = ({
|
||||
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const handleTriggerMore = useCallback(() => {
|
||||
setShowMore(true)
|
||||
}, [setShowMore])
|
||||
|
||||
const primaryOps = useMemo(() => {
|
||||
if (operations)
|
||||
@ -169,43 +172,44 @@ const AppOperations = ({
|
||||
</Button>
|
||||
))}
|
||||
{shouldShowMoreButton && (
|
||||
<PortalToFollowElem
|
||||
open={showMore}
|
||||
onOpenChange={setShowMore}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="gap-px"
|
||||
>
|
||||
<DropdownMenu open={showMore} onOpenChange={setShowMore}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="gap-px"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{t('operation.more', { ns: 'common' })}
|
||||
</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-30">
|
||||
<div className="flex min-w-[264px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{moreOperations.map(item => item.type === 'divider'
|
||||
? (
|
||||
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
|
||||
)
|
||||
: (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
|
||||
<span className="system-md-regular text-text-secondary">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[264px]"
|
||||
>
|
||||
{moreOperations.map(item => item.type === 'divider'
|
||||
? (
|
||||
<DropdownMenuSeparator key={item.id} />
|
||||
)
|
||||
: (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="gap-x-1 px-1.5"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
|
||||
<span className="system-md-regular text-text-secondary">{item.title}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -5,14 +5,14 @@ import {
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Divider from '../base/divider'
|
||||
@ -34,16 +34,7 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const [detailExpand, setDetailExpand] = useState(false)
|
||||
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
@ -51,27 +42,28 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-2 left-2 z-20">
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: -41,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
|
||||
<AppIcon
|
||||
size="small"
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
|
||||
open && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
size="small"
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<RiMenuLine className="h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
|
||||
<div className="p-2">
|
||||
<div
|
||||
@ -114,8 +106,8 @@ const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="z-20">
|
||||
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
|
||||
|
||||
@ -137,14 +137,6 @@ vi.mock('@/app/components/datasets/rename-modal', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('Dropdown callback coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -159,7 +151,7 @@ describe('Dropdown callback coverage', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
@ -175,7 +167,7 @@ describe('Dropdown callback coverage', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
@ -190,7 +182,7 @@ describe('Dropdown callback coverage', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -210,7 +202,7 @@ describe('Dropdown callback coverage', () => {
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -224,7 +216,7 @@ describe('Dropdown callback coverage', () => {
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
await waitFor(() => {
|
||||
@ -232,6 +224,27 @@ describe('Dropdown callback coverage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not attempt export when the dataset has no pipeline id', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDataset = createDataset({ pipeline_id: '' })
|
||||
|
||||
render(<Dropdown expand={false} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
expect(mockExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render and open correctly when collapsed', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown expand={false} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should surface the backend message when checking app usage fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCheckIsUsedInApp.mockRejectedValueOnce({
|
||||
@ -240,7 +253,7 @@ describe('Dropdown callback coverage', () => {
|
||||
|
||||
render(<Dropdown expand />)
|
||||
|
||||
await user.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
@ -22,6 +21,7 @@ const mockInvalidDatasetDetail = vi.fn()
|
||||
const mockExportPipeline = vi.fn()
|
||||
const mockCheckIsUsedInApp = vi.fn()
|
||||
const mockDeleteDataset = vi.fn()
|
||||
const TestEditIcon = () => <span aria-hidden className="i-ri-edit-line" />
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
@ -210,7 +210,7 @@ describe('MenuItem', () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClick = vi.fn()
|
||||
// Arrange
|
||||
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
|
||||
render(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('Edit'))
|
||||
@ -225,7 +225,7 @@ describe('MenuItem', () => {
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />
|
||||
<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
@ -236,7 +236,7 @@ describe('MenuItem', () => {
|
||||
})
|
||||
|
||||
it('should not crash when no click handler is provided', () => {
|
||||
render(<MenuItem name="Edit" Icon={RiEditLine} />)
|
||||
render(<MenuItem name="Edit" Icon={TestEditIcon} />)
|
||||
|
||||
const event = createEvent.click(screen.getByText('Edit'))
|
||||
fireEvent(screen.getByText('Edit'), event)
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -14,7 +13,6 @@ import { useInvalid } from '@/service/use-base'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import ActionButton from '../../base/action-button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -24,6 +22,11 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '../../base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../base/ui/dropdown-menu'
|
||||
import RenameDatasetModal from '../../datasets/rename-modal'
|
||||
import Menu from './menu'
|
||||
|
||||
@ -44,10 +47,6 @@ const DropDown = ({
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
|
||||
|
||||
@ -57,9 +56,11 @@ const DropDown = ({
|
||||
}, [invalidDatasetDetail, invalidDatasetList])
|
||||
|
||||
const openRenameModal = useCallback(() => {
|
||||
setShowRenameModal(true)
|
||||
handleTrigger()
|
||||
}, [handleTrigger])
|
||||
setOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowRenameModal(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
@ -67,7 +68,7 @@ const DropDown = ({
|
||||
const { pipeline_id, name } = dataset
|
||||
if (!pipeline_id)
|
||||
return
|
||||
handleTrigger()
|
||||
setOpen(false)
|
||||
try {
|
||||
const { data } = await exportPipelineConfig({
|
||||
pipelineId: pipeline_id,
|
||||
@ -79,9 +80,10 @@ const DropDown = ({
|
||||
catch {
|
||||
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, handleTrigger, t])
|
||||
}, [dataset, exportPipelineConfig, t])
|
||||
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
|
||||
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
|
||||
@ -91,10 +93,7 @@ const DropDown = ({
|
||||
const res = await e.json()
|
||||
toast(res?.message || 'Unknown error', { type: 'error' })
|
||||
}
|
||||
finally {
|
||||
handleTrigger()
|
||||
}
|
||||
}, [dataset.id, handleTrigger, t])
|
||||
}, [dataset.id, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
@ -109,32 +108,27 @@ const DropDown = ({
|
||||
}, [dataset.id, replace, invalidDatasetList, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={expand ? 'bottom-end' : 'right'}
|
||||
offset={expand
|
||||
? {
|
||||
mainAxis: 4,
|
||||
crossAxis: 10,
|
||||
}
|
||||
: {
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
|
||||
<RiMoreFill className="size-4" />
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md', open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-60">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={expand ? 'bottom-end' : 'right-start'}
|
||||
sideOffset={4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<Menu
|
||||
showDelete={!isCurrentWorkspaceDatasetOperator}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</DropdownMenuContent>
|
||||
{showRenameModal && (
|
||||
<RenameDatasetModal
|
||||
show={showRenameModal}
|
||||
@ -163,7 +157,7 @@ const DropDown = ({
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -5,13 +5,13 @@ import {
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
@ -41,15 +41,7 @@ const DatasetSidebarDropdown = ({
|
||||
|
||||
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
|
||||
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
@ -66,32 +58,28 @@ const DatasetSidebarDropdown = ({
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-2 left-2 z-20">
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: -41,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
|
||||
open && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
size="small"
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<RiMenuLine className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
|
||||
open && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
size="small"
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<RiMenuLine className="size-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg">
|
||||
<Effect className="top-[-22px] -left-5 opacity-15" />
|
||||
<div className="flex flex-col gap-y-2 p-4">
|
||||
@ -155,8 +143,8 @@ const DatasetSidebarDropdown = ({
|
||||
documentCount={dataset.document_count}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -22,24 +22,57 @@ vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react')
|
||||
const OpenContext = ReactModule.createContext(false)
|
||||
const OpenContext = ReactModule.createContext<{ open: boolean, setOpen: (nextOpen: boolean) => void } | null>(null)
|
||||
|
||||
const useOpenContext = () => {
|
||||
const context = ReactModule.use(OpenContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext.Provider value={open}>
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<OpenContext.Provider value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="portal-root">{children}</div>
|
||||
</OpenContext.Provider>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
|
||||
<div className={className} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const open = ReactModule.useContext(OpenContext)
|
||||
return open ? <div className={className}>{children}</div> : null
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
const { open, setOpen } = useOpenContext()
|
||||
|
||||
if (render) {
|
||||
return ReactModule.cloneElement(render, {
|
||||
onClick: () => setOpen(!open),
|
||||
} as Record<string, unknown>, children)
|
||||
}
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({ children, popupClassName }: { children: React.ReactNode, popupClassName?: string }) => {
|
||||
const context = useOpenContext()
|
||||
return context.open ? <div className={popupClassName}>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
|
||||
const { setOpen } = useOpenContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -4,12 +4,13 @@ import type { Model, ModelItem } from '@/app/components/header/account-setting/m
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
|
||||
@ -50,61 +51,57 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
const handleToggle = () => {
|
||||
if (validModelConfigs.length)
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
const handleSelect = (item: ModelAndParameter) => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<PortalToFollowElemTrigger className="w-full" onClick={handleToggle}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!validModelConfigs.length}
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={!validModelConfigs.length}
|
||||
render={(
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!validModelConfigs.length}
|
||||
className="mt-3 w-full"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{t('operation.applyConfig', { ns: 'appDebug' })}
|
||||
<RiArrowDownSLine className="ml-0.5 h-3 w-3" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50 mt-1 w-[288px]">
|
||||
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
{t('publishAs', { ns: 'appDebug' })}
|
||||
</div>
|
||||
{
|
||||
validModelConfigs.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<span className="min-w-[18px] italic">
|
||||
#
|
||||
{index + 1}
|
||||
</span>
|
||||
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
|
||||
<div
|
||||
className="ml-1 truncate text-text-secondary"
|
||||
title={item.modelItem.label[language]}
|
||||
>
|
||||
{item.modelItem.label[language]}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[288px] p-1"
|
||||
>
|
||||
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
|
||||
{t('publishAs', { ns: 'appDebug' })}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{
|
||||
validModelConfigs.map((item, index) => (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
className="gap-0 px-3"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<span className="min-w-[18px] italic">
|
||||
#
|
||||
{index + 1}
|
||||
</span>
|
||||
<ModelIcon modelName={item.model} provider={item.providerItem} className="ml-2" />
|
||||
<div
|
||||
className="ml-1 truncate text-text-secondary"
|
||||
title={item.modelItem.label[language]}
|
||||
>
|
||||
{item.modelItem.label[language]}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { ModelAndParameter } from '../../types'
|
||||
import type { Item } from '@/app/components/base/dropdown'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import DebugItem from '../debug-item'
|
||||
@ -10,12 +10,6 @@ const mockUseDebugConfigurationContext = vi.fn()
|
||||
const mockUseDebugWithMultipleModelContext = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
|
||||
let capturedDropdownProps: {
|
||||
onSelect: (item: Item) => void
|
||||
items: Item[]
|
||||
secondItems?: Item[]
|
||||
} | null = null
|
||||
|
||||
let capturedModelParameterTriggerProps: {
|
||||
modelAndParameter: ModelAndParameter
|
||||
} | null = null
|
||||
@ -51,34 +45,6 @@ vi.mock('../model-parameter-trigger', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/dropdown', () => ({
|
||||
default: (props: { onSelect: (item: Item) => void, items: Item[], secondItems?: Item[] }) => {
|
||||
capturedDropdownProps = props
|
||||
return (
|
||||
<div data-testid="dropdown">
|
||||
{props.items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`dropdown-item-${item.value}`}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
{props.secondItems?.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
data-testid={`dropdown-second-item-${item.value}`}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
|
||||
id: 'model-1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
@ -117,7 +83,6 @@ const renderComponent = (props: Partial<DebugItemProps> = {}) => {
|
||||
describe('DebugItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedDropdownProps = null
|
||||
capturedModelParameterTriggerProps = null
|
||||
|
||||
mockUseDebugConfigurationContext.mockReturnValue({
|
||||
@ -137,12 +102,18 @@ describe('DebugItem', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const openMenu = async () => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button'))
|
||||
return user
|
||||
}
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render with basic props', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('model-parameter-trigger'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct index number', () => {
|
||||
@ -280,7 +251,7 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
describe('dropdown menu', () => {
|
||||
it('should show duplicate option when less than 4 models', () => {
|
||||
it('should show duplicate option when less than 4 models', async () => {
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [createModelAndParameter()],
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
@ -288,13 +259,12 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.items).toContainEqual(
|
||||
expect.objectContaining({ value: 'duplicate' }),
|
||||
)
|
||||
expect(screen.getByText('appDebug.duplicateModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide duplicate option when 4 or more models', () => {
|
||||
it('should hide duplicate option when 4 or more models', async () => {
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -307,52 +277,48 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'duplicate' }),
|
||||
)
|
||||
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show debug-as-single-model option when provider and model are set', () => {
|
||||
it('should show debug-as-single-model option when provider and model are set', async () => {
|
||||
renderComponent({
|
||||
modelAndParameter: createModelAndParameter({
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
}),
|
||||
})
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.items).toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
expect(screen.getByText('appDebug.debugAsSingleModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide debug-as-single-model option when provider is missing', () => {
|
||||
it('should hide debug-as-single-model option when provider is missing', async () => {
|
||||
renderComponent({
|
||||
modelAndParameter: createModelAndParameter({
|
||||
provider: '',
|
||||
model: 'gpt-3.5-turbo',
|
||||
}),
|
||||
})
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide debug-as-single-model option when model is missing', () => {
|
||||
it('should hide debug-as-single-model option when model is missing', async () => {
|
||||
renderComponent({
|
||||
modelAndParameter: createModelAndParameter({
|
||||
provider: 'openai',
|
||||
model: '',
|
||||
}),
|
||||
})
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.items).not.toContainEqual(
|
||||
expect.objectContaining({ value: 'debug-as-single-model' }),
|
||||
)
|
||||
expect(screen.queryByText('appDebug.debugAsSingleModel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show remove option in secondItems when more than 2 models', () => {
|
||||
it('should show remove option in secondItems when more than 2 models', async () => {
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -364,13 +330,12 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.secondItems).toContainEqual(
|
||||
expect.objectContaining({ value: 'remove' }),
|
||||
)
|
||||
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show remove option when 2 or fewer models', () => {
|
||||
it('should not show remove option when 2 or fewer models', async () => {
|
||||
mockUseDebugWithMultipleModelContext.mockReturnValue({
|
||||
multipleModelConfigs: [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -381,13 +346,14 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(capturedDropdownProps?.secondItems).toBeUndefined()
|
||||
expect(screen.queryByText('common.operation.remove')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown actions', () => {
|
||||
it('should duplicate model when duplicate is selected', () => {
|
||||
it('should duplicate model when duplicate is selected', async () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
const originalModel = createModelAndParameter({ id: 'original' })
|
||||
|
||||
@ -399,7 +365,8 @@ describe('DebugItem', () => {
|
||||
|
||||
renderComponent({ modelAndParameter: originalModel })
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
|
||||
const user = await openMenu()
|
||||
await user.click(screen.getByText('appDebug.duplicateModel'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
@ -414,7 +381,7 @@ describe('DebugItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not duplicate when already at 4 models', () => {
|
||||
it('should not duplicate when already at 4 models', async () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
const models = [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -430,14 +397,13 @@ describe('DebugItem', () => {
|
||||
})
|
||||
|
||||
renderComponent({ modelAndParameter: models[0] })
|
||||
|
||||
// Since duplicate is not shown when >= 4 models, we need to manually call handleSelect
|
||||
capturedDropdownProps?.onSelect({ value: 'duplicate', text: 'Duplicate' })
|
||||
await openMenu()
|
||||
|
||||
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('appDebug.duplicateModel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', () => {
|
||||
it('should call onDebugWithMultipleModelChange when debug-as-single-model is selected', async () => {
|
||||
const onDebugWithMultipleModelChange = vi.fn()
|
||||
const modelAndParameter = createModelAndParameter()
|
||||
|
||||
@ -449,12 +415,13 @@ describe('DebugItem', () => {
|
||||
|
||||
renderComponent({ modelAndParameter })
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
|
||||
const user = await openMenu()
|
||||
await user.click(screen.getByText('appDebug.debugAsSingleModel'))
|
||||
|
||||
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
|
||||
})
|
||||
|
||||
it('should remove model when remove is selected', () => {
|
||||
it('should remove model when remove is selected', async () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
const models = [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -470,7 +437,8 @@ describe('DebugItem', () => {
|
||||
|
||||
renderComponent({ modelAndParameter: models[1] })
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
|
||||
const user = await openMenu()
|
||||
await user.click(screen.getByText('common.operation.remove'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
@ -478,7 +446,7 @@ describe('DebugItem', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should insert duplicated model at correct position', () => {
|
||||
it('should insert duplicated model at correct position', async () => {
|
||||
const onMultipleModelConfigsChange = vi.fn()
|
||||
const models = [
|
||||
createModelAndParameter({ id: '1' }),
|
||||
@ -495,7 +463,8 @@ describe('DebugItem', () => {
|
||||
// Duplicate the second model
|
||||
renderComponent({ modelAndParameter: models[1] })
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
|
||||
const user = await openMenu()
|
||||
await user.click(screen.getByText('appDebug.duplicateModel'))
|
||||
|
||||
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
|
||||
true,
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import type { CSSProperties, FC } from 'react'
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import type { Item } from '@/app/components/base/dropdown'
|
||||
import { memo } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Dropdown from '@/app/components/base/dropdown'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDebugConfigurationContext } from '@/context/debug-configuration'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -35,34 +41,43 @@ const DebugItem: FC<DebugItemProps> = ({
|
||||
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
|
||||
const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSelect = (item: Item) => {
|
||||
if (item.value === 'duplicate') {
|
||||
if (multipleModelConfigs.length >= 4)
|
||||
return
|
||||
const handleDuplicate = () => {
|
||||
setOpen(false)
|
||||
if (multipleModelConfigs.length >= 4)
|
||||
return
|
||||
|
||||
onMultipleModelConfigsChange(
|
||||
true,
|
||||
[
|
||||
...multipleModelConfigs.slice(0, index + 1),
|
||||
{
|
||||
...modelAndParameter,
|
||||
id: `${Date.now()}`,
|
||||
},
|
||||
...multipleModelConfigs.slice(index + 1),
|
||||
],
|
||||
)
|
||||
}
|
||||
if (item.value === 'debug-as-single-model')
|
||||
onDebugWithMultipleModelChange(modelAndParameter)
|
||||
if (item.value === 'remove') {
|
||||
onMultipleModelConfigsChange(
|
||||
true,
|
||||
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
|
||||
)
|
||||
}
|
||||
onMultipleModelConfigsChange(
|
||||
true,
|
||||
[
|
||||
...multipleModelConfigs.slice(0, index + 1),
|
||||
{
|
||||
...modelAndParameter,
|
||||
id: `${Date.now()}`,
|
||||
},
|
||||
...multipleModelConfigs.slice(index + 1),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleDebugAsSingleModel = () => {
|
||||
setOpen(false)
|
||||
onDebugWithMultipleModelChange(modelAndParameter)
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
setOpen(false)
|
||||
onMultipleModelConfigsChange(
|
||||
true,
|
||||
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
|
||||
)
|
||||
}
|
||||
|
||||
const showDuplicate = multipleModelConfigs.length <= 3
|
||||
const showDebugAsSingleModel = !!(modelAndParameter.provider && modelAndParameter.model)
|
||||
const showRemove = multipleModelConfigs.length > 2
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[320px] flex-col rounded-xl bg-background-section-burn ${className}`}
|
||||
@ -76,41 +91,37 @@ const DebugItem: FC<DebugItemProps> = ({
|
||||
<ModelParameterTrigger
|
||||
modelAndParameter={modelAndParameter}
|
||||
/>
|
||||
<Dropdown
|
||||
onSelect={handleSelect}
|
||||
items={[
|
||||
...(
|
||||
multipleModelConfigs.length <= 3
|
||||
? [
|
||||
{
|
||||
value: 'duplicate',
|
||||
text: t('duplicateModel', { ns: 'appDebug' }),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(
|
||||
(modelAndParameter.provider && modelAndParameter.model)
|
||||
? [
|
||||
{
|
||||
value: 'debug-as-single-model',
|
||||
text: t('debugAsSingleModel', { ns: 'appDebug' }),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
]}
|
||||
secondItems={
|
||||
multipleModelConfigs.length > 2
|
||||
? [
|
||||
{
|
||||
value: 'remove',
|
||||
text: t('operation.remove', { ns: 'common' }) as string,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[160px]"
|
||||
>
|
||||
{showDuplicate && (
|
||||
<DropdownMenuItem className="system-md-regular" onClick={handleDuplicate}>
|
||||
{t('duplicateModel', { ns: 'appDebug' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showDebugAsSingleModel && (
|
||||
<DropdownMenuItem className="system-md-regular" onClick={handleDebugAsSingleModel}>
|
||||
{t('debugAsSingleModel', { ns: 'appDebug' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showRemove && (
|
||||
<>
|
||||
{(showDuplicate || showDebugAsSingleModel) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem variant="destructive" className="system-md-regular" onClick={handleRemove}>
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div style={{ height: 'calc(100% - 40px)' }}>
|
||||
{
|
||||
|
||||
@ -30,7 +30,7 @@ const actionButtonVariants = cva(
|
||||
},
|
||||
)
|
||||
|
||||
export type ActionButtonProps = {
|
||||
type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
@ -73,4 +73,4 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
export default ActionButton
|
||||
export { ActionButton, ActionButtonState, actionButtonVariants }
|
||||
export { ActionButton, ActionButtonState }
|
||||
|
||||
@ -211,8 +211,9 @@ describe('HeaderInMobile', () => {
|
||||
fireEvent.click(screen.getByText(/share\.chat\.viewChatSettings/i))
|
||||
|
||||
// Check if chat settings overlay is open
|
||||
// Check if chat settings overlay is open
|
||||
expect(screen.getByTestId('mobile-chat-settings-overlay'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-chat-settings-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close chat settings via overlay click
|
||||
fireEvent.click(screen.getByTestId('mobile-chat-settings-overlay'))
|
||||
@ -236,7 +237,9 @@ describe('HeaderInMobile', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText(/share\.chat\.viewChatSettings/i))
|
||||
|
||||
expect(screen.getByTestId('mobile-chat-settings-overlay'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-chat-settings-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click inside the settings panel (find the title)
|
||||
const settingsTitle = screen.getByText(/share\.chat\.chatSettingsTitle/i)
|
||||
@ -282,7 +285,9 @@ describe('HeaderInMobile', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByText(/share\.chat\.resetChat/i))
|
||||
|
||||
expect(handleNewConversation).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(handleNewConversation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle pin conversation', async () => {
|
||||
@ -348,8 +353,7 @@ describe('HeaderInMobile', () => {
|
||||
fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
// RenameModal should be visible
|
||||
// RenameModal should be visible
|
||||
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
const input = screen.getByDisplayValue('Conv 1')
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
|
||||
@ -377,8 +381,7 @@ describe('HeaderInMobile', () => {
|
||||
fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
// RenameModal should be visible
|
||||
// RenameModal should be visible
|
||||
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByRole('button', { name: /common\.operation\.cancel/i })
|
||||
@ -410,8 +413,7 @@ describe('HeaderInMobile', () => {
|
||||
fireEvent.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
// RenameModal should be visible with loading state
|
||||
// RenameModal should be visible with loading state
|
||||
expect(screen.getByRole('dialog'))!.toBeInTheDocument()
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle delete conversation', async () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -53,11 +53,16 @@ describe('MobileOperationDropdown Component', () => {
|
||||
|
||||
// Reset Chat
|
||||
await user.click(screen.getByText('share.chat.resetChat'))
|
||||
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.handleResetChat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
// View Chat Settings
|
||||
await user.click(screen.getByText('share.chat.viewChatSettings'))
|
||||
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.handleViewChatSettings).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('applies hover state to ActionButton when open', async () => {
|
||||
@ -72,4 +77,16 @@ describe('MobileOperationDropdown Component', () => {
|
||||
await user.click(trigger)
|
||||
expect(trigger).toHaveClass('action-btn-hover')
|
||||
})
|
||||
|
||||
it('closes the menu after clicking an action', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<MobileOperationDropdown {...defaultProps} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('share.chat.resetChat'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('share.chat.resetChat')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -74,12 +74,18 @@ describe('Operation Component', () => {
|
||||
expect(defaultProps.togglePin).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Rename
|
||||
await user.click(screen.getByText('Chat Title'))
|
||||
await user.click(screen.getByText('explore.sidebar.action.rename'))
|
||||
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onRenameConversation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Delete
|
||||
await user.click(screen.getByText('Chat Title'))
|
||||
await user.click(screen.getByText('explore.sidebar.action.delete'))
|
||||
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('applies hover background when open', async () => {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type Props = {
|
||||
handleResetChat: () => void
|
||||
@ -16,40 +21,45 @@ const MobileOperationDropdown = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleMenuAction = useCallback((callback: () => void) => {
|
||||
setOpen(false)
|
||||
queueMicrotask(callback)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
data-testid="mobile-more-btn"
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<div className="i-ri-more-fill h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<div
|
||||
className="min-w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[160px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={() => handleMenuAction(handleResetChat)}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" onClick={handleResetChat}>
|
||||
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
|
||||
</div>
|
||||
{!hideViewChatSettings && (
|
||||
<div className="flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover" onClick={handleViewChatSettings}>
|
||||
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<span className="grow">{t('chat.resetChat', { ns: 'share' })}</span>
|
||||
</DropdownMenuItem>
|
||||
{!hideViewChatSettings && (
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={() => handleMenuAction(handleViewChatSettings)}
|
||||
>
|
||||
<span className="grow">{t('chat.viewChatSettings', { ns: 'share' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
import type { FC } from 'react'
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
@ -33,42 +35,51 @@ const Operation: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleDeferredAction = useCallback((action: () => void) => {
|
||||
setOpen(false)
|
||||
queueMicrotask(action)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={placement}
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg p-1.5 pl-2 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<div className="system-md-semibold">{title}</div>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div
|
||||
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
|
||||
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={placement}
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[120px]"
|
||||
>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={togglePin}>
|
||||
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
{isShowRenameConversation && (
|
||||
<DropdownMenuItem
|
||||
className="system-md-regular"
|
||||
onClick={() => onRenameConversation && handleDeferredAction(onRenameConversation)}
|
||||
>
|
||||
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="system-md-regular"
|
||||
onClick={() => handleDeferredAction(onDelete)}
|
||||
>
|
||||
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(Operation)
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Operation from '../operation'
|
||||
|
||||
// Mock PortalToFollowElem components to render children in place
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => <div data-open={open}>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => <div onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
|
||||
}))
|
||||
|
||||
describe('Operation', () => {
|
||||
const defaultProps = {
|
||||
isActive: false,
|
||||
@ -72,7 +65,9 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('explore.sidebar.action.rename'))
|
||||
|
||||
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onRenameConversation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onDelete when delete is clicked', async () => {
|
||||
@ -82,7 +77,9 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('explore.sidebar.action.delete'))
|
||||
|
||||
expect(defaultProps.onDelete).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect visibility props', async () => {
|
||||
@ -108,8 +105,7 @@ describe('Operation', () => {
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const portalContent = screen.getByTestId('portal-content')
|
||||
expect(portalContent).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close dropdown when item hovering stops', async () => {
|
||||
@ -120,5 +116,60 @@ describe('Operation', () => {
|
||||
expect(screen.getByText('explore.sidebar.action.pin')).toBeInTheDocument()
|
||||
|
||||
rerender(<Operation {...defaultProps} isItemHovering={false} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the trigger mounted while visually hidden', () => {
|
||||
render(<Operation {...defaultProps} isItemHovering={false} />)
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
expect(trigger).toHaveClass('pointer-events-none')
|
||||
expect(trigger).toHaveClass('opacity-0')
|
||||
})
|
||||
|
||||
it('should safely ignore rename clicks when callback is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Operation {...defaultProps} onRenameConversation={undefined} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('explore.sidebar.action.rename'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('explore.sidebar.action.rename')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not bubble trigger clicks to the parent container', async () => {
|
||||
const user = userEvent.setup()
|
||||
const parentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<Operation {...defaultProps} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not bubble popup clicks to the parent container', async () => {
|
||||
const user = userEvent.setup()
|
||||
const parentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<Operation {...defaultProps} isItemHovering={true} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByRole('menu'))
|
||||
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiMoreFill,
|
||||
RiPushpinLine,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type Props = {
|
||||
isActive?: boolean
|
||||
@ -38,24 +36,29 @@ const Operation: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
|
||||
useEffect(() => {
|
||||
if (!isItemHovering && !isHovering)
|
||||
setOpen(false)
|
||||
}, [isItemHovering, isHovering])
|
||||
const handleDeferredAction = useCallback((action?: () => void) => {
|
||||
if (!action)
|
||||
return
|
||||
setOpen(false)
|
||||
queueMicrotask(action)
|
||||
}, [])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
|
||||
className={cn((isItemHovering || open) ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
@ -64,39 +67,57 @@ const Operation: FC<Props> = ({
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
|
||||
onMouseEnter={setIsHovering}
|
||||
onMouseLeave={setNotHovering}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[120px]"
|
||||
popupProps={{
|
||||
onMouseEnter: setIsHovering,
|
||||
onMouseLeave: setNotHovering,
|
||||
onClick: e => e.stopPropagation(),
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-2 system-md-regular"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin()
|
||||
}}
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
{isPinned && <RiUnpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
{!isPinned && <RiPushpinLine className="h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn('flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<RiEditLine className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn('group flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 system-md-regular text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete}>
|
||||
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover:text-text-destructive')} />
|
||||
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{isPinned && <span aria-hidden className="i-ri-unpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
{!isPinned && <span aria-hidden className="i-ri-pushpin-line h-4 w-4 shrink-0 text-text-tertiary" />}
|
||||
<span className="grow">{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
{isShowRenameConversation && (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-2 system-md-regular"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeferredAction(onRenameConversation)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-edit-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('sidebar.action.rename', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-2 system-md-regular"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeferredAction(onDelete)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4 shrink-0" />
|
||||
<span className="grow">{t('sidebar.action.delete', { ns: 'explore' })}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(Operation)
|
||||
|
||||
@ -1,225 +0,0 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Dropdown from '../index'
|
||||
|
||||
describe('Dropdown Component', () => {
|
||||
const mockItems = [
|
||||
{ value: 'option1', text: 'Option 1' },
|
||||
{ value: 'option2', text: 'Option 2' },
|
||||
]
|
||||
const mockSecondItems = [
|
||||
{ value: 'option3', text: 'Option 3' },
|
||||
]
|
||||
const onSelect = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders default trigger properly', () => {
|
||||
const { container } = render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = container.querySelector('button')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom trigger when provided', () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
onSelect={onSelect}
|
||||
renderTrigger={open => <button data-testid="custom-trigger">{open ? 'Open' : 'Closed'}</button>}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByTestId('custom-trigger')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveTextContent('Closed')
|
||||
})
|
||||
|
||||
it('opens dropdown menu on trigger click and shows items', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
// Dropdown items are rendered in a portal (document.body)
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect and closes dropdown when an item is clicked', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
const option1 = screen.getByText('Option 1')
|
||||
await act(async () => {
|
||||
fireEvent.click(option1)
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(mockItems[0])
|
||||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect and closes dropdown when a second item is clicked', async () => {
|
||||
render(
|
||||
<Dropdown items={mockItems} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const option3 = screen.getByText('Option 3')
|
||||
await act(async () => {
|
||||
fireEvent.click(option3)
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledWith(mockSecondItems[0])
|
||||
expect(screen.queryByText('Option 3')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders second items and divider when provided', async () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
secondItems={mockSecondItems}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
||||
|
||||
// Check for divider (h-px bg-divider-regular)
|
||||
const divider = document.body.querySelector('.bg-divider-regular.h-px')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom classNames', async () => {
|
||||
const popupClass = 'custom-popup'
|
||||
const itemClass = 'custom-item'
|
||||
const secondItemClass = 'custom-second-item'
|
||||
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
secondItems={mockSecondItems}
|
||||
onSelect={onSelect}
|
||||
popupClassName={popupClass}
|
||||
itemClassName={itemClass}
|
||||
secondItemClassName={secondItemClass}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const popup = document.body.querySelector(`.${popupClass}`)
|
||||
expect(popup).toBeInTheDocument()
|
||||
|
||||
const items = screen.getAllByText('Option 1')
|
||||
expect(items[0]).toHaveClass(itemClass)
|
||||
|
||||
const secondItems = screen.getAllByText('Option 3')
|
||||
expect(secondItems[0]).toHaveClass(secondItemClass)
|
||||
})
|
||||
|
||||
it('applies open class to trigger when menu is open', async () => {
|
||||
render(<Dropdown items={mockItems} onSelect={onSelect} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
fireEvent.click(trigger)
|
||||
})
|
||||
expect(trigger).toHaveClass('bg-divider-regular')
|
||||
})
|
||||
|
||||
it('handles JSX elements as item text', async () => {
|
||||
const itemsWithJSX = [
|
||||
{ value: 'jsx', text: <span data-testid="jsx-item">JSX Content</span> },
|
||||
]
|
||||
render(
|
||||
<Dropdown items={itemsWithJSX} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('jsx-item')).toBeInTheDocument()
|
||||
expect(screen.getByText('JSX Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render items section if items list is empty', async () => {
|
||||
render(
|
||||
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
|
||||
const p1Divs = document.body.querySelectorAll('.p-1')
|
||||
expect(p1Divs.length).toBe(1)
|
||||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render divider if only one section is provided', async () => {
|
||||
const { rerender } = render(
|
||||
<Dropdown items={mockItems} onSelect={onSelect} />,
|
||||
)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Dropdown items={[]} secondItems={mockSecondItems} onSelect={onSelect} />,
|
||||
)
|
||||
})
|
||||
expect(document.body.querySelector('.bg-divider-regular.h-px')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing if both item lists are empty', async () => {
|
||||
render(<Dropdown items={[]} secondItems={[]} onSelect={onSelect} />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
})
|
||||
const popup = document.body.querySelector('.bg-components-panel-bg')
|
||||
expect(popup?.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('passes triggerProps to ActionButton and applies custom className', () => {
|
||||
render(
|
||||
<Dropdown
|
||||
items={mockItems}
|
||||
onSelect={onSelect}
|
||||
triggerProps={{
|
||||
'disabled': true,
|
||||
'aria-label': 'dropdown-trigger',
|
||||
'className': 'custom-trigger-class',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const trigger = screen.getByLabelText('dropdown-trigger')
|
||||
expect(trigger).toBeDisabled()
|
||||
expect(trigger).toHaveClass('custom-trigger-class')
|
||||
})
|
||||
})
|
||||
@ -1,88 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { Item } from '.'
|
||||
import { useState } from 'react'
|
||||
import { fn } from 'storybook/test'
|
||||
import Dropdown from '.'
|
||||
|
||||
const PRIMARY_ITEMS: Item[] = [
|
||||
{ value: 'rename', text: 'Rename' },
|
||||
{ value: 'duplicate', text: 'Duplicate' },
|
||||
]
|
||||
|
||||
const SECONDARY_ITEMS: Item[] = [
|
||||
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
|
||||
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/Dropdown',
|
||||
component: Dropdown,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
secondItems: SECONDARY_ITEMS,
|
||||
},
|
||||
} satisfies Meta<typeof Dropdown>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
|
||||
const [lastAction, setLastAction] = useState<string>('None')
|
||||
|
||||
return (
|
||||
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
|
||||
<Dropdown
|
||||
{...props}
|
||||
onSelect={(item) => {
|
||||
setLastAction(String(item.value))
|
||||
props.onSelect?.(item)
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
|
||||
Last action:
|
||||
{' '}
|
||||
<span className="font-mono text-text-primary">{lastAction}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: args => <DropdownDemo {...args} />,
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
secondItems: SECONDARY_ITEMS,
|
||||
onSelect: fn(),
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomTrigger: Story = {
|
||||
render: args => (
|
||||
<DropdownDemo
|
||||
{...args}
|
||||
renderTrigger={open => (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
|
||||
>
|
||||
Actions
|
||||
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
items: PRIMARY_ITEMS,
|
||||
onSelect: fn(),
|
||||
},
|
||||
}
|
||||
@ -1,122 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ActionButtonProps } from '@/app/components/base/action-button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
export type Item = {
|
||||
value: string | number
|
||||
text: string | React.JSX.Element
|
||||
}
|
||||
type DropdownProps = {
|
||||
items: Item[]
|
||||
secondItems?: Item[]
|
||||
onSelect: (item: Item) => void
|
||||
renderTrigger?: (open: boolean) => React.ReactNode
|
||||
triggerProps?: ActionButtonProps
|
||||
popupClassName?: string
|
||||
itemClassName?: string
|
||||
secondItemClassName?: string
|
||||
}
|
||||
const Dropdown: FC<DropdownProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
secondItems,
|
||||
renderTrigger,
|
||||
triggerProps,
|
||||
popupClassName,
|
||||
itemClassName,
|
||||
secondItemClassName,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleSelect = (item: Item) => {
|
||||
setOpen(false)
|
||||
onSelect(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger(open)
|
||||
: (
|
||||
<ActionButton
|
||||
{...triggerProps}
|
||||
className={cn(
|
||||
open && 'bg-divider-regular',
|
||||
triggerProps?.className,
|
||||
)}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupClassName}>
|
||||
<div className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg text-sm text-text-secondary shadow-lg">
|
||||
{
|
||||
!!items.length && (
|
||||
<div className="p-1">
|
||||
{
|
||||
items.map(item => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
itemClassName,
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
(!!items.length && !!secondItems?.length) && (
|
||||
<div className="h-px bg-divider-regular" />
|
||||
)
|
||||
}
|
||||
{
|
||||
!!secondItems?.length && (
|
||||
<div className="p-1">
|
||||
{
|
||||
secondItems.map(item => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
secondItemClassName,
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dropdown
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Breadcrumbs from '../index'
|
||||
|
||||
@ -44,6 +44,16 @@ const resetMockStoreState = () => {
|
||||
mockStoreState.setBucket = vi.fn()
|
||||
}
|
||||
|
||||
const getDropdownTrigger = () => {
|
||||
return document.querySelector('[aria-haspopup="menu"]') as HTMLElement | null
|
||||
}
|
||||
|
||||
const openCollapsedBreadcrumbDropdown = () => {
|
||||
const dropdownTrigger = getDropdownTrigger()
|
||||
expect(dropdownTrigger).toBeInTheDocument()
|
||||
fireEvent.click(dropdownTrigger as HTMLElement)
|
||||
}
|
||||
|
||||
describe('Breadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -437,15 +447,11 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Act - Click on dropdown trigger (the ... button)
|
||||
const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg'))
|
||||
if (dropdownTrigger)
|
||||
fireEvent.click(dropdownTrigger)
|
||||
openCollapsedBreadcrumbDropdown()
|
||||
|
||||
// Assert - Collapsed breadcrumbs should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('folder3'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('folder4'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(await screen.findByText('folder3')).toBeInTheDocument()
|
||||
expect(await screen.findByText('folder4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -615,9 +621,7 @@ describe('Breadcrumbs', () => {
|
||||
|
||||
// Assert - Should collapse because 3 > 2
|
||||
// Dropdown should be present
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg'))
|
||||
expect(hasDropdownTrigger).toBe(true)
|
||||
expect(getDropdownTrigger()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => {
|
||||
@ -647,9 +651,7 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Assert - Should collapse because 3 > 2
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg'))
|
||||
expect(hasDropdownTrigger).toBe(true)
|
||||
expect(getDropdownTrigger()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -722,23 +724,16 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Act - Click dropdown to see collapsed items
|
||||
const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg'))
|
||||
if (dropdownTrigger)
|
||||
fireEvent.click(dropdownTrigger)
|
||||
openCollapsedBreadcrumbDropdown()
|
||||
|
||||
// prefixBreadcrumbs = ['f1', 'f2']
|
||||
// collapsedBreadcrumbs = ['f3', 'f4']
|
||||
// lastBreadcrumb = 'f5'
|
||||
// prefixBreadcrumbs = ['f1', 'f2']
|
||||
// collapsedBreadcrumbs = ['f3', 'f4']
|
||||
// lastBreadcrumb = 'f5'
|
||||
expect(screen.getByText('f1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('f2'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('f5'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('f3'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('f4'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('f1')).toBeInTheDocument()
|
||||
expect(screen.getByText('f2')).toBeInTheDocument()
|
||||
expect(screen.getByText('f5')).toBeInTheDocument()
|
||||
expect(await screen.findByText('f3')).toBeInTheDocument()
|
||||
expect(await screen.findByText('f4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => {
|
||||
@ -883,15 +878,8 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Act - Open dropdown and click on collapsed breadcrumb (f3, index=2)
|
||||
const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg'))
|
||||
if (dropdownTrigger)
|
||||
fireEvent.click(dropdownTrigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('f3'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('f3'))
|
||||
openCollapsedBreadcrumbDropdown()
|
||||
fireEvent.click(await screen.findByText('f3'))
|
||||
|
||||
// Assert - Should slice to index 2 + 1 = 3
|
||||
expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['f1', 'f2', 'f3'])
|
||||
@ -954,18 +942,13 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Act - Open dropdown
|
||||
const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg'))
|
||||
if (dropdownTrigger)
|
||||
fireEvent.click(dropdownTrigger)
|
||||
openCollapsedBreadcrumbDropdown()
|
||||
|
||||
// Assert - First, last, and collapsed should be accessible
|
||||
// Assert - First, last, and collapsed should be accessible
|
||||
expect(screen.getByText('folder-0'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('folder-1'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('folder-19'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('folder-2'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('folder-0')).toBeInTheDocument()
|
||||
expect(screen.getByText('folder-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('folder-19')).toBeInTheDocument()
|
||||
expect(await screen.findByText('folder-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty bucket string', () => {
|
||||
@ -1026,9 +1009,7 @@ describe('Breadcrumbs', () => {
|
||||
render(<Breadcrumbs {...props} />)
|
||||
|
||||
// Assert - Should collapse because breadcrumbs.length > expectedNum
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg'))
|
||||
expect(hasDropdownTrigger).toBe(true)
|
||||
expect(getDropdownTrigger()).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -32,10 +32,10 @@ describe('Dropdown', () => {
|
||||
|
||||
const { container } = render(<Dropdown {...props} />)
|
||||
|
||||
// Assert - Button should have RiMoreFill icon (rendered as svg)
|
||||
// Assert - Button should have the more icon
|
||||
const button = screen.getByRole('button')
|
||||
expect(button)!.toBeInTheDocument()
|
||||
expect(container.querySelector('svg'))!.toBeInTheDocument()
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-more-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render separator after dropdown', () => {
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import Menu from './menu'
|
||||
|
||||
type DropdownProps = {
|
||||
@ -22,26 +21,17 @@ const Dropdown = ({
|
||||
}: DropdownProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
onBreadcrumbClick(index)
|
||||
setOpen(false)
|
||||
}, [onBreadcrumbClick])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -13,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@ -49,18 +39,22 @@ const Dropdown = ({
|
||||
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<Menu
|
||||
breadcrumbs={breadcrumbs}
|
||||
startIndex={startIndex}
|
||||
onBreadcrumbClick={handleBreadCrumbClick}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</DropdownMenuContent>
|
||||
<span className="system-xs-regular text-divider-deep">/</span>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ItemOperation from '../index'
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => {
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
popupProps,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return <div data-testid="dropdown-content" {...popupProps}>{children}</div>
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('ItemOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -67,14 +142,27 @@ describe('ItemOperation', () => {
|
||||
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onRenameConversation when clicking rename action', async () => {
|
||||
const onRenameConversation = vi.fn()
|
||||
renderComponent({
|
||||
isShowRenameConversation: true,
|
||||
onRenameConversation,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.rename'))
|
||||
|
||||
expect(onRenameConversation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
const pinText = await screen.findByText('explore.sidebar.action.pin')
|
||||
const menu = pinText.closest('div')?.parentElement as HTMLElement
|
||||
await screen.findByText('explore.sidebar.action.pin')
|
||||
const menu = screen.getByTestId('dropdown-content')
|
||||
|
||||
fireEvent.mouseEnter(menu)
|
||||
fireEvent.mouseLeave(menu)
|
||||
@ -83,5 +171,25 @@ describe('ItemOperation', () => {
|
||||
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop propagation when clicking inside the dropdown content', async () => {
|
||||
const onParentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={onParentClick}>
|
||||
<ItemOperation
|
||||
isPinned={false}
|
||||
isShowDelete
|
||||
togglePin={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByTestId('dropdown-content'))
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -7,10 +7,14 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { Pin02 } from '../../base/icons/src/vender/line/general'
|
||||
import s from './style.module.css'
|
||||
|
||||
@ -35,61 +39,74 @@ const ItemOperation: FC<IItemOperationProps> = ({
|
||||
isShowDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation('explore')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
|
||||
useEffect(() => {
|
||||
if (!isItemHovering && !isHovering)
|
||||
setOpen(false)
|
||||
}, [isItemHovering, isHovering])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<DropdownMenuTrigger
|
||||
data-testid="item-operation-trigger"
|
||||
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} bg-components-actionbar-bg! shadow-none!`)}
|
||||
data-testid="item-operation-trigger"
|
||||
>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-50"
|
||||
<span className="sr-only">{tCommon('operation.more')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[120px]"
|
||||
popupProps={{
|
||||
onMouseEnter: setIsHovering,
|
||||
onMouseLeave: setNotHovering,
|
||||
onClick: e => e.stopPropagation(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-[120px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]"
|
||||
onMouseEnter={setIsHovering}
|
||||
onMouseLeave={setNotHovering}
|
||||
<DropdownMenuItem
|
||||
className={cn(s.actionItem, 'gap-2 px-3')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePin()
|
||||
}}
|
||||
>
|
||||
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin', { ns: 'explore' }) : t('sidebar.action.pin', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn(s.actionItem, 'group hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
<span className={s.actionName}>{t('sidebar.action.rename', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn(s.actionItem, s.deleteActionItem, 'group hover:bg-state-base-hover')} onClick={onDelete}>
|
||||
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-text-secondary')} />
|
||||
<span className={cn(s.actionName, s.deleteActionItemChild)}>{t('sidebar.action.delete', { ns: 'explore' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<Pin02 className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
<span className={s.actionName}>{isPinned ? t('sidebar.action.unpin') : t('sidebar.action.pin')}</span>
|
||||
</DropdownMenuItem>
|
||||
{isShowRenameConversation && (
|
||||
<DropdownMenuItem
|
||||
className={cn(s.actionItem, 'gap-2 px-3')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRenameConversation?.()
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 shrink-0 text-text-secondary" />
|
||||
<span className={s.actionName}>{t('sidebar.action.rename')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<DropdownMenuItem
|
||||
className={cn(s.actionItem, s.deleteActionItem, 'gap-2 px-3 data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className={cn(s.deleteActionItemChild, 'h-4 w-4 shrink-0 stroke-current stroke-2 text-inherit')} />
|
||||
<span className={cn(s.actionName, s.deleteActionItemChild, 'text-inherit')}>{t('sidebar.action.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(ItemOperation)
|
||||
|
||||
@ -188,10 +188,12 @@ describe('Card Component', () => {
|
||||
fireEvent.click(screen.getByText(/operation.edit/))
|
||||
|
||||
// Assert
|
||||
expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', {
|
||||
apiKey: 'key1',
|
||||
__name__: 'Credential 1',
|
||||
__credential_id__: 'c1',
|
||||
await waitFor(() => {
|
||||
expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', {
|
||||
apiKey: 'key1',
|
||||
__name__: 'Credential 1',
|
||||
__credential_id__: 'c1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -202,7 +204,9 @@ describe('Card Component', () => {
|
||||
fireEvent.click(screen.getByText(/operation.remove/))
|
||||
|
||||
// Assert
|
||||
expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1')
|
||||
await waitFor(() => {
|
||||
expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle "setDefault" action from Item component', async () => {
|
||||
@ -212,7 +216,9 @@ describe('Card Component', () => {
|
||||
fireEvent.click(screen.getByText(/auth.setDefault/))
|
||||
|
||||
// Assert
|
||||
expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1')
|
||||
await waitFor(() => {
|
||||
expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle "rename" action from Item component', async () => {
|
||||
@ -231,14 +237,16 @@ describe('Card Component', () => {
|
||||
fireEvent.click(screen.getByText(/operation.rename/))
|
||||
|
||||
// Now it should show an input
|
||||
const input = screen.getByPlaceholderText(/placeholder.input/)
|
||||
const input = await screen.findByPlaceholderText(/placeholder.input/)
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
fireEvent.click(screen.getByText(/operation.save/))
|
||||
|
||||
// Assert
|
||||
expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({
|
||||
credential_id: 'c1',
|
||||
name: 'New Name',
|
||||
await waitFor(() => {
|
||||
expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({
|
||||
credential_id: 'c1',
|
||||
name: 'New Name',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DataSourceCredential } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Item from '../item'
|
||||
|
||||
@ -14,6 +14,9 @@ const triggerRename = async () => {
|
||||
fireEvent.click(dropdownTrigger)
|
||||
const renameOption = await screen.findByText('common.operation.rename')
|
||||
fireEvent.click(renameOption)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.placeholder.input')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
describe('Item Component', () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { DataSourceCredential } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Operator from '../operator'
|
||||
|
||||
@ -9,10 +10,6 @@ import Operator from '../operator'
|
||||
*/
|
||||
|
||||
// Helper to open dropdown
|
||||
const openDropdown = () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
describe('Operator Component', () => {
|
||||
const mockOnAction = vi.fn()
|
||||
const mockOnRename = vi.fn()
|
||||
@ -37,7 +34,7 @@ describe('Operator Component', () => {
|
||||
|
||||
// Act
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
|
||||
@ -53,7 +50,7 @@ describe('Operator Component', () => {
|
||||
|
||||
// Act
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
|
||||
@ -71,11 +68,13 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
|
||||
// Act
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
fireEvent.click(await screen.findByText('common.operation.rename'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnRename).toHaveBeenCalledTimes(1)
|
||||
await waitFor(() => {
|
||||
expect(mockOnRename).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockOnAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -85,7 +84,7 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} />)
|
||||
|
||||
// Act & Assert
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
const renameBtn = await screen.findByText('common.operation.rename')
|
||||
expect(() => fireEvent.click(renameBtn)).not.toThrow()
|
||||
})
|
||||
@ -96,11 +95,13 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
|
||||
// Act
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
fireEvent.click(await screen.findByText('plugin.auth.setDefault'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
|
||||
await waitFor(() => {
|
||||
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAction for "edit" action', async () => {
|
||||
@ -109,11 +110,13 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
|
||||
// Act
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
fireEvent.click(await screen.findByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
|
||||
await waitFor(() => {
|
||||
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAction for "change" action', async () => {
|
||||
@ -122,11 +125,13 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
|
||||
// Act
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
|
||||
await waitFor(() => {
|
||||
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAction for "delete" action', async () => {
|
||||
@ -135,11 +140,13 @@ describe('Operator Component', () => {
|
||||
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
|
||||
|
||||
// Act
|
||||
openDropdown()
|
||||
await userEvent.setup().click(screen.getByRole('button'))
|
||||
fireEvent.click(await screen.findByText('common.operation.remove'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
|
||||
await waitFor(() => {
|
||||
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,21 +1,20 @@
|
||||
import type {
|
||||
DataSourceCredential,
|
||||
} from './types'
|
||||
import type { Item } from '@/app/components/base/dropdown'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiHome9Line,
|
||||
RiStickyNoteAddLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Dropdown from '@/app/components/base/dropdown'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
|
||||
type OperatorProps = {
|
||||
@ -29,106 +28,60 @@ const Operator = ({
|
||||
onRename,
|
||||
}: OperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const {
|
||||
type,
|
||||
} = credentialItem
|
||||
const items = useMemo(() => {
|
||||
const commonItems = [
|
||||
{
|
||||
value: 'setDefault',
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiHome9Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(
|
||||
type === CredentialTypeEnum.OAUTH2
|
||||
? [
|
||||
{
|
||||
value: 'rename',
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEditLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(
|
||||
type === CredentialTypeEnum.API_KEY
|
||||
? [
|
||||
{
|
||||
value: 'edit',
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiEqualizer2Line className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
]
|
||||
if (type === CredentialTypeEnum.OAUTH2) {
|
||||
const oAuthItems = [
|
||||
{
|
||||
value: 'change',
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiStickyNoteAddLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
commonItems.push(...oAuthItems)
|
||||
}
|
||||
return commonItems
|
||||
}, [t, type])
|
||||
|
||||
const secondItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: 'delete',
|
||||
text: (
|
||||
<div className="flex items-center">
|
||||
<RiDeleteBinLine className="mr-2 h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [])
|
||||
const handleSelect = useCallback((item: Item) => {
|
||||
if (item.value === 'rename') {
|
||||
onRename?.()
|
||||
return
|
||||
}
|
||||
onAction(
|
||||
item.value as string,
|
||||
credentialItem,
|
||||
)
|
||||
}, [onAction, credentialItem, onRename])
|
||||
const handleAction = useCallback((action: string) => {
|
||||
setOpen(false)
|
||||
queueMicrotask(() => {
|
||||
if (action === 'rename') {
|
||||
onRename?.()
|
||||
return
|
||||
}
|
||||
onAction(action, credentialItem)
|
||||
})
|
||||
}, [credentialItem, onAction, onRename])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={items}
|
||||
secondItems={secondItems}
|
||||
onSelect={handleSelect}
|
||||
popupClassName="z-1002"
|
||||
triggerProps={{
|
||||
size: 'l',
|
||||
}}
|
||||
itemClassName="py-2 h-auto hover:bg-state-base-hover"
|
||||
secondItemClassName="py-2 h-auto hover:bg-state-base-hover"
|
||||
/>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton size="l" className={open ? 'bg-state-base-hover' : ''}>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[200px]">
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('setDefault')}>
|
||||
<span aria-hidden className="i-ri-home-9-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('auth.setDefault', { ns: 'plugin' })}</div>
|
||||
</DropdownMenuItem>
|
||||
{type === CredentialTypeEnum.OAUTH2 && (
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('rename')}>
|
||||
<span aria-hidden className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.rename', { ns: 'common' })}</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{type === CredentialTypeEnum.API_KEY && (
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('edit')}>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="system-sm-semibold text-text-secondary">{t('operation.edit', { ns: 'common' })}</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{type === CredentialTypeEnum.OAUTH2 && (
|
||||
<DropdownMenuItem className="h-auto gap-2 py-2" onClick={() => handleAction('change')}>
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line h-4 w-4 text-text-tertiary" />
|
||||
<div className="mb-1 system-sm-semibold text-text-secondary">{t('dataSource.notion.changeAuthorizedPages', { ns: 'common' })}</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" className="h-auto gap-2 py-2" onClick={() => handleAction('delete')}>
|
||||
<span aria-hidden className="i-ri-delete-bin-line h-4 w-4" />
|
||||
<div className="system-sm-semibold">
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
'use client'
|
||||
import type { Member } from '@/models/common'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
|
||||
@ -74,40 +79,50 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<PortalToFollowElem open={open} onOpenChange={setOpen} placement="bottom-end" offset={{ mainAxis: 4 }}>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
|
||||
<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs')}>
|
||||
<div className="p-1">
|
||||
{roleList.map(role => (
|
||||
<div key={role} className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
|
||||
{role === member.role
|
||||
? <CheckIcon className="mt-[2px] mr-1 h-4 w-4 text-text-accent" />
|
||||
: <div className="mt-[2px] mr-1 h-4 w-4 text-text-accent" />}
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
|
||||
<div className="mt-[2px] mr-1 h-4 w-4 text-text-accent" />
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
|
||||
>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="inline-flex flex-col rounded-xl p-0"
|
||||
>
|
||||
<div className="p-1">
|
||||
{roleList.map(role => (
|
||||
<DropdownMenuItem
|
||||
key={role}
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={() => handleUpdateMemberRole(role)}
|
||||
>
|
||||
{role === member.role
|
||||
? <span aria-hidden className="mt-[2px] i-ri-check-line h-4 w-4 shrink-0 text-text-accent" />
|
||||
: <span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />}
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={handleDeleteMemberOrCancelInvitation}
|
||||
>
|
||||
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default memo(Operation)
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import type {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SortDropdown from '../index'
|
||||
|
||||
// ================================
|
||||
// Mock external dependencies only
|
||||
// ================================
|
||||
|
||||
// Mock i18n translation hook
|
||||
const mockTranslation = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.sortBy': 'Sort by',
|
||||
@ -27,7 +24,6 @@ vi.mock('#i18n', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock marketplace atoms with controllable values
|
||||
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const mockHandleSortChange = vi.fn()
|
||||
|
||||
@ -35,664 +31,123 @@ vi.mock('../../atoms', () => ({
|
||||
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
|
||||
}))
|
||||
|
||||
// Mock portal component with controllable open state
|
||||
let mockPortalOpenState = false
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
mockPortalOpenState = open
|
||||
return (
|
||||
<div data-testid="portal-wrapper" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
// Match actual behavior: only render when portal is open
|
||||
if (!mockPortalOpenState)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Test Factory Functions
|
||||
// ================================
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-wrapper" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, className }: { children: ReactNode, className?: string }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-testid="dropdown-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div data-testid="dropdown-content">{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
type SortOption = {
|
||||
value: string
|
||||
order: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const createSortOptions = (): SortOption[] => [
|
||||
{ value: 'install_count', order: 'DESC', text: 'Most Popular' },
|
||||
{ value: 'version_updated_at', order: 'DESC', text: 'Recently Updated' },
|
||||
{ value: 'created_at', order: 'DESC', text: 'Newly Released' },
|
||||
{ value: 'created_at', order: 'ASC', text: 'First Released' },
|
||||
]
|
||||
|
||||
// ================================
|
||||
// SortDropdown Component Tests
|
||||
// ================================
|
||||
describe('SortDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
mockPortalOpenState = false
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Rendering Tests
|
||||
// ================================
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<SortDropdown />)
|
||||
it('renders the selected sort option in the trigger', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort by label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Sort by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected option text', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow down icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrowIcon = container.querySelector('.h-4.w-4.text-text-tertiary')
|
||||
expect(arrowIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element with correct styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
expect(trigger).toHaveClass('h-8', 'rounded-lg', 'bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should not render dropdown content when closed', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
const trigger = screen.getByTestId('dropdown-trigger')
|
||||
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// State Management Tests
|
||||
// ================================
|
||||
describe('State Management', () => {
|
||||
it('should initialize with closed state', () => {
|
||||
render(<SortDropdown />)
|
||||
it('falls back to the default option when the current sort is invalid', () => {
|
||||
mockSort = { sortBy: 'unknown', sortOrder: 'ASC' }
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
render(<SortDropdown />)
|
||||
|
||||
it('should display correct selected option for install_count DESC', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for version_updated_at DESC', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at DESC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Newly Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct selected option for created_at ASC', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// After click, portal content should be visible
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger clicked again', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Open
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
|
||||
// Close
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// User Interactions Tests
|
||||
// ================================
|
||||
describe('User Interactions', () => {
|
||||
it('should open dropdown on trigger click', () => {
|
||||
render(<SortDropdown />)
|
||||
it('opens the menu and renders all sort options', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
fireEvent.click(trigger)
|
||||
await user.click(screen.getByTestId('dropdown-trigger'))
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sort options when open', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
|
||||
expect(within(content).getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleSortChange when option clicked', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Click on "Recently Updated"
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'version_updated_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Most Popular', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for Newly Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call handleSortChange with correct params for First Released', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow selecting currently selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Most Popular'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'install_count',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should support userEvent for trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
const content = screen.getByTestId('dropdown-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Recently Updated')).toBeInTheDocument()
|
||||
expect(within(content).getByText('Newly Released')).toBeInTheDocument()
|
||||
expect(within(content).getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Check Icon Tests
|
||||
// ================================
|
||||
describe('Check Icon', () => {
|
||||
it('should show check icon for selected option', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
it('shows a check icon for the currently selected option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
// Open dropdown
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
await user.click(screen.getByTestId('dropdown-trigger'))
|
||||
|
||||
// Check icon should be present in the dropdown
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show check icon only for matching sortBy AND sortOrder', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// "Newly Released" (created_at DESC) should have check icon
|
||||
// "First Released" (created_at ASC) should NOT have check icon
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should not show check icon for different sortOrder with same sortBy', () => {
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// Only one check icon should be visible (for Newly Released, not First Released)
|
||||
const checkIcons = container.querySelectorAll('.text-text-accent')
|
||||
expect(checkIcons.length).toBe(1)
|
||||
})
|
||||
expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Dropdown Options Structure Tests
|
||||
// ================================
|
||||
describe('Dropdown Options Structure', () => {
|
||||
const sortOptions = createSortOptions()
|
||||
it('updates the sort and closes the menu when an option is selected', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortDropdown />)
|
||||
|
||||
it('should render 4 sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
await user.click(screen.getByTestId('dropdown-trigger'))
|
||||
await user.click(screen.getByText('Recently Updated'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBe(4)
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'version_updated_at',
|
||||
sortOrder: 'DESC',
|
||||
})
|
||||
|
||||
it.each(sortOptions)('should render option: $text', ({ text }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options with unique keys', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
|
||||
// All options should be rendered (no key conflicts)
|
||||
expect(options.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should render dropdown container with correct styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.firstChild as HTMLElement
|
||||
expect(container).toHaveClass('rounded-xl', 'shadow-lg')
|
||||
})
|
||||
|
||||
it('should render option items with hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.cursor-pointer')
|
||||
expect(option).toHaveClass('hover:bg-components-panel-on-panel-item-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Edge Cases Tests
|
||||
// ================================
|
||||
describe('Edge Cases', () => {
|
||||
// The component falls back to the first option (Most Popular) when sort values are invalid
|
||||
|
||||
it('should fallback to default option when sortBy is unknown', () => {
|
||||
mockSort = { sortBy: 'unknown_field', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Should fallback to first option "Most Popular"
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortBy is empty', () => {
|
||||
mockSort = { sortBy: '', sortOrder: 'DESC' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default option when sortOrder is unknown', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'UNKNOWN' }
|
||||
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly when handleSortChange is a no-op', () => {
|
||||
mockHandleSortChange.mockImplementation(() => {})
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rapid toggle clicks', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
|
||||
// Rapid clicks
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Final state should be open (odd number of clicks)
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple option selections', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
|
||||
// Click multiple options
|
||||
fireEvent.click(within(content).getByText('Recently Updated'))
|
||||
fireEvent.click(within(content).getByText('Newly Released'))
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Context Integration Tests
|
||||
// ================================
|
||||
describe('Context Integration', () => {
|
||||
it('should read sort value from context', () => {
|
||||
mockSort = { sortBy: 'version_updated_at', sortOrder: 'DESC' }
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Recently Updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call context handleSortChange on selection', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText('First Released'))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'ASC',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update display when context sort changes', () => {
|
||||
const { rerender } = render(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument()
|
||||
|
||||
// Simulate context change
|
||||
mockSort = { sortBy: 'created_at', sortOrder: 'ASC' }
|
||||
rerender(<SortDropdown />)
|
||||
|
||||
expect(screen.getByText('First Released')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use selector pattern correctly', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
// Component should have called useMarketplaceContext with selector functions
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Accessibility Tests
|
||||
// ================================
|
||||
describe('Accessibility', () => {
|
||||
it('should have cursor pointer on trigger', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const trigger = container.querySelector('.cursor-pointer')
|
||||
expect(trigger).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have cursor pointer on options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const options = content.querySelectorAll('.cursor-pointer')
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have visible focus indicators via hover styles', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const option = content.querySelector('.hover\\:bg-components-panel-on-panel-item-bg-hover')
|
||||
expect(option).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Translation Tests
|
||||
// ================================
|
||||
describe('Translations', () => {
|
||||
it('should call translation for sortBy label', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortBy', { ns: 'plugin' })
|
||||
})
|
||||
|
||||
it('should call translation for all sort options', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.mostPopular', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.recentlyUpdated', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.newlyReleased', { ns: 'plugin' })
|
||||
expect(mockTranslation).toHaveBeenCalledWith('marketplace.sortOption.firstReleased', { ns: 'plugin' })
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Portal Component Integration Tests
|
||||
// ================================
|
||||
describe('Portal Component Integration', () => {
|
||||
it('should pass open state to PortalToFollowElem', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const wrapper = screen.getByTestId('portal-wrapper')
|
||||
expect(wrapper).toHaveAttribute('data-open', 'false')
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(wrapper).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should render trigger content inside PortalToFollowElemTrigger', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
const trigger = screen.getByTestId('portal-trigger')
|
||||
expect(within(trigger).getByText('Sort by')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render options inside PortalToFollowElemContent', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
expect(within(content).getByText('Most Popular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Visual Style Tests
|
||||
// ================================
|
||||
describe('Visual Styles', () => {
|
||||
it('should apply correct trigger container styles', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const triggerDiv = container.querySelector('.flex.h-8.cursor-pointer.items-center.rounded-lg')
|
||||
expect(triggerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply secondary text color to sort by label', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const label = container.querySelector('.text-text-secondary')
|
||||
expect(label).toBeInTheDocument()
|
||||
expect(label?.textContent).toBe('Sort by')
|
||||
})
|
||||
|
||||
it('should apply primary text color to selected option', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const selected = container.querySelector('.text-text-primary.system-sm-medium')
|
||||
expect(selected).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply tertiary text color to arrow icon', () => {
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
const arrow = container.querySelector('.text-text-tertiary')
|
||||
expect(arrow).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply accent text color to check icon when option selected', () => {
|
||||
mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
|
||||
const { container } = render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const checkIcon = container.querySelector('.text-text-accent')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blur-sm backdrop to dropdown container', () => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
const container = content.querySelector('.backdrop-blur-xs')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ================================
|
||||
// All Sort Options Click Tests
|
||||
// ================================
|
||||
describe('All Sort Options Click Handlers', () => {
|
||||
const testCases = [
|
||||
{ text: 'Most Popular', sortBy: 'install_count', sortOrder: 'DESC' },
|
||||
{ text: 'Recently Updated', sortBy: 'version_updated_at', sortOrder: 'DESC' },
|
||||
{ text: 'Newly Released', sortBy: 'created_at', sortOrder: 'DESC' },
|
||||
{ text: 'First Released', sortBy: 'created_at', sortOrder: 'ASC' },
|
||||
]
|
||||
|
||||
it.each(testCases)(
|
||||
'should call handleSortChange with { sortBy: "$sortBy", sortOrder: "$sortOrder" } when clicking "$text"',
|
||||
({ text, sortBy, sortOrder }) => {
|
||||
render(<SortDropdown />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
const content = screen.getByTestId('portal-content')
|
||||
fireEvent.click(within(content).getByText(text))
|
||||
|
||||
expect(mockHandleSortChange).toHaveBeenCalledWith({ sortBy, sortOrder })
|
||||
},
|
||||
)
|
||||
expect(screen.queryByTestId('dropdown-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
'use client'
|
||||
import { useTranslation } from '#i18n'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useMarketplaceSort } from '../atoms'
|
||||
|
||||
const SortDropdown = () => {
|
||||
@ -38,50 +35,44 @@ const SortDropdown = () => {
|
||||
]
|
||||
const [sort, handleSortChange] = useMarketplaceSort()
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]
|
||||
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]!
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className="flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3">
|
||||
<span className="mr-1 system-sm-regular text-text-secondary">
|
||||
{t('marketplace.sortBy', { ns: 'plugin' })}
|
||||
</span>
|
||||
<span className="mr-1 system-sm-medium text-text-primary">
|
||||
{selectedOption!.text}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={`${option.value}-${option.order}`}
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 pr-2 system-md-regular text-text-primary hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
onClick={() => handleSortChange({ sortBy: option.value, sortOrder: option.order })}
|
||||
>
|
||||
{option.text}
|
||||
{
|
||||
sort.sortBy === option.value && sort.sortOrder === option.order && (
|
||||
<RiCheckLine className="ml-2 h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenuTrigger className="flex h-8 cursor-pointer items-center rounded-lg bg-state-base-hover-alt px-2 pr-3">
|
||||
<span className="mr-1 system-sm-regular text-text-secondary">
|
||||
{t('marketplace.sortBy', { ns: 'plugin' })}
|
||||
</span>
|
||||
<span className="mr-1 system-sm-medium text-text-primary">
|
||||
{selectedOption.text}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="p-1"
|
||||
>
|
||||
{options.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={`${option.value}-${option.order}`}
|
||||
className="justify-between px-3 pr-2 system-md-regular text-text-primary"
|
||||
onClick={() => {
|
||||
handleSortChange({ sortBy: option.value, sortOrder: option.order })
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{option.text}
|
||||
{sort.sortBy === option.value && sort.sortOrder === option.order && (
|
||||
<span aria-hidden className="ml-2 i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -36,34 +36,85 @@ vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/button', () => ({
|
||||
Button: ({ children }: { children: React.ReactNode }) => <span data-testid="button-content">{children}</span>,
|
||||
Button: ({ children, onClick, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" data-testid="button-content" className={className} onClick={onClick} {...props}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
portalOpen = open
|
||||
return <div>{children}</div>
|
||||
return (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="dropdown-trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({
|
||||
onClick?: React.MouseEventHandler<HTMLElement>
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
|
||||
|
||||
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="dropdown-content">{children}</div> : null,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-item"
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -131,13 +182,13 @@ describe('InstallPluginDropdown', () => {
|
||||
expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens the github installer when github is selected', () => {
|
||||
it('opens the github installer when github is selected', async () => {
|
||||
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.source.github'))
|
||||
|
||||
expect(screen.getByTestId('github-modal')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('github-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the local package installer when a file is selected', () => {
|
||||
@ -153,4 +204,40 @@ describe('InstallPluginDropdown', () => {
|
||||
expect(screen.getByTestId('local-modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.difypkg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('triggers the hidden file input when local is selected from the menu', () => {
|
||||
const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||||
|
||||
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.source.local'))
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('closes the github installer when the modal requests close', async () => {
|
||||
render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByText('plugin.source.github'))
|
||||
fireEvent.click(await screen.findByTestId('close-github-modal'))
|
||||
|
||||
expect(screen.queryByTestId('github-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the local package installer when the modal requests close', () => {
|
||||
const { container } = render(<InstallPluginDropdown onSwitchToMarketplaceTab={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.change(container.querySelector('input[type="file"]')!, {
|
||||
target: {
|
||||
files: [new File(['content'], 'plugin.difypkg')],
|
||||
},
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('close-local-modal'))
|
||||
|
||||
expect(screen.queryByTestId('local-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,12 +8,13 @@ import { useTranslation } from 'react-i18next'
|
||||
import { FileZip } from '@/app/components/base/icons/src/vender/solid/files'
|
||||
import { Github } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github'
|
||||
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
|
||||
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
|
||||
@ -77,61 +78,66 @@ const InstallPluginDropdown = ({
|
||||
}
|
||||
}, [plugin_installation_permission, enable_marketplace, t])
|
||||
|
||||
const handleInstallMethodSelect = (action: string) => {
|
||||
if (action === 'local') {
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'marketplace') {
|
||||
onSwitchToMarketplaceTab()
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
setSelectedAction(action)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger onClick={() => setIsMenuOpen(v => !v)}>
|
||||
<Button
|
||||
className={cn('h-full w-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
className={cn('h-full w-full p-2 text-components-button-secondary-text', isMenuOpen && 'bg-state-base-hover')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="pl-1">{t('installPlugin', { ns: 'plugin' })}</span>
|
||||
<RiArrowDownSLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className="shadows-shadow-lg flex w-[200px] flex-col items-start rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 pb-2">
|
||||
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-2 system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('installFrom', { ns: 'plugin' })}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
|
||||
/>
|
||||
<div className="w-full">
|
||||
{installMethods.map(({ icon: Icon, text, action }) => (
|
||||
<div
|
||||
key={action}
|
||||
className="flex w-full cursor-pointer! items-center gap-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
if (action === 'local') {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
else if (action === 'marketplace') {
|
||||
onSwitchToMarketplaceTab()
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
else {
|
||||
setSelectedAction(action)
|
||||
setIsMenuOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[200px] pb-2"
|
||||
>
|
||||
<span className="flex items-start self-stretch pt-1 pr-3 pb-0.5 pl-3 system-xs-medium-uppercase text-text-tertiary">
|
||||
{t('installFrom', { ns: 'plugin' })}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
|
||||
/>
|
||||
{installMethods.map(({ icon: Icon, text, action }) => (
|
||||
<DropdownMenuItem
|
||||
key={action}
|
||||
className="gap-1 px-2"
|
||||
onClick={() => handleInstallMethodSelect(action)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="px-1 system-md-regular text-text-secondary">{text}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</div>
|
||||
{selectedAction === 'github' && (
|
||||
<InstallFromGitHub
|
||||
@ -150,7 +156,7 @@ const InstallPluginDropdown = ({
|
||||
{/* {pluginLists.map((item: any) => (
|
||||
<div key={item.id} onClick={() => handleUninstall(item.id)}>{item.name} 卸载</div>
|
||||
))} */}
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -685,6 +685,26 @@ describe('PluginTasks Component', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the menu after clearing the last non-running plugins', async () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /task\.clearAll/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.w-\\[360px\\]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear only error plugins when onClearErrors is called', async () => {
|
||||
const { mockMutateAsync } = setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'error-1' }),
|
||||
@ -797,6 +817,30 @@ describe('PluginTasks Component', () => {
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open for installing-with-success state', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
|
||||
createMockPlugin({ status: TaskStatus.success, plugin_unique_identifier: 'success-1' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open for installing-with-error state', () => {
|
||||
setupMocks([
|
||||
createMockPlugin({ status: TaskStatus.running, plugin_unique_identifier: 'running-1' }),
|
||||
createMockPlugin({ status: TaskStatus.failed, plugin_unique_identifier: 'failed-1' }),
|
||||
])
|
||||
|
||||
render(<PluginTasks />)
|
||||
fireEvent.click(document.getElementById('plugin-task-trigger')!)
|
||||
|
||||
expect(document.querySelector('.w-\\[360px\\]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -202,6 +202,7 @@ describe('PluginItem', () => {
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(1)
|
||||
expect(clearButton).toHaveClass('invisible', 'flex', 'group-hover/item:visible')
|
||||
})
|
||||
|
||||
it('should not render clear button when onClear is not provided', () => {
|
||||
|
||||
@ -108,7 +108,7 @@ const ErrorPluginItem: FC<ErrorPluginItemProps> = ({ plugin, getIconUrl, languag
|
||||
</span>
|
||||
)}
|
||||
statusText={(
|
||||
<span className="whitespace-pre-line">
|
||||
<span className="block max-w-full break-words whitespace-pre-line">
|
||||
{plugin.message || errorMsg}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -39,7 +39,7 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
<div className="truncate system-sm-medium text-text-secondary">
|
||||
{plugin.labels[language]}
|
||||
</div>
|
||||
<div className={`system-xs-regular ${statusClassName || 'text-text-tertiary'}`}>
|
||||
<div className={`min-w-0 system-xs-regular break-words ${statusClassName || 'text-text-tertiary'}`}>
|
||||
{statusText}
|
||||
</div>
|
||||
{action}
|
||||
@ -47,7 +47,7 @@ const PluginItem: FC<PluginItemProps> = ({
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md group-hover/item:flex hover:bg-state-base-hover-alt"
|
||||
className="invisible flex h-6 w-6 shrink-0 items-center justify-center self-start rounded-md group-hover/item:visible hover:bg-state-base-hover-alt"
|
||||
onClick={onClear}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -43,7 +43,7 @@ const PluginSection: FC<PluginSectionProps> = ({
|
||||
)
|
||||
{headerAction}
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{plugins.map(plugin => (
|
||||
<PluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
|
||||
@ -86,7 +86,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
|
||||
{t('task.clearAll', { ns: 'plugin' })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{errorPlugins.map(plugin => (
|
||||
<ErrorPluginItem
|
||||
key={plugin.plugin_unique_identifier}
|
||||
|
||||
@ -5,10 +5,10 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
|
||||
import PluginTaskList from './components/plugin-task-list'
|
||||
import TaskStatusIndicator from './components/task-status-indicator'
|
||||
@ -33,6 +33,7 @@ const PluginTasks = () => {
|
||||
handleClearErrorPlugin,
|
||||
} = usePluginTaskStatus()
|
||||
const { getIconUrl } = useGetIcon()
|
||||
const canOpenMenu = isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess
|
||||
|
||||
// Generate tooltip text based on status
|
||||
const tip = useMemo(() => {
|
||||
@ -85,27 +86,20 @@ const PluginTasks = () => {
|
||||
[clearPluginsAndClose],
|
||||
)
|
||||
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
if (isFailed || isInstalling || isInstallingWithSuccess || isInstallingWithError || isSuccess)
|
||||
setOpen(v => !v)
|
||||
}, [isFailed, isInstalling, isInstallingWithSuccess, isInstallingWithError, isSuccess])
|
||||
|
||||
// Hide when no plugin tasks
|
||||
if (totalPluginsLength === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 79,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTriggerClick}>
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
disabled={!canOpenMenu}
|
||||
>
|
||||
<TaskStatusIndicator
|
||||
tip={tip}
|
||||
isInstalling={isInstalling}
|
||||
@ -118,8 +112,12 @@ const PluginTasks = () => {
|
||||
totalPluginsLength={totalPluginsLength}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<PluginTaskList
|
||||
runningPlugins={runningPlugins}
|
||||
successPlugins={successPlugins}
|
||||
@ -129,8 +127,8 @@ const PluginTasks = () => {
|
||||
onClearErrors={handleClearErrors}
|
||||
onClearSingle={handleClearSingle}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,6 +3,27 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MenuDropdown from '../menu-dropdown'
|
||||
|
||||
vi.mock('../info-modal', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
onClose,
|
||||
data,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
data?: SiteInfo
|
||||
}) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="info-modal">
|
||||
<span>{data?.title}</span>
|
||||
<button type="button" onClick={onClose}>Close Info</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPathname = '/test-path'
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@ -191,6 +212,25 @@ describe('MenuDropdown', () => {
|
||||
expect(screen.getByText('Test App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close InfoModal when the close handler runs', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('common.userProfile.about')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.userProfile.about'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('info-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Close Info'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('info-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('forceClose prop', () => {
|
||||
|
||||
@ -1,26 +1,25 @@
|
||||
'use client'
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
import type { FC } from 'react'
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
import Divider from '../../base/divider'
|
||||
import InfoModal from './info-modal'
|
||||
|
||||
type Props = {
|
||||
@ -40,24 +39,22 @@ const MenuDropdown: FC<Props> = ({
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const shareCode = useWebAppStore(s => s.shareCode)
|
||||
const handleLogout = useCallback(async () => {
|
||||
setOpen(false)
|
||||
await webAppLogout(shareCode!)
|
||||
router.replace(`/webapp-signin?redirect_url=${pathname}`)
|
||||
}, [router, pathname, webAppLogout, shareCode])
|
||||
}, [pathname, router, setOpen, shareCode])
|
||||
|
||||
const [show, setShow] = useState(false)
|
||||
const handleOpenInfoModal = useCallback(() => {
|
||||
setOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShow(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (forceClose)
|
||||
@ -66,60 +63,56 @@ const MenuDropdown: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={placement || 'bottom-end'}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div>
|
||||
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiEqualizer2Line className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-1">
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg py-1.5 pr-2 pl-3 system-md-regular text-text-secondary')}>
|
||||
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<DropdownMenuTrigger
|
||||
render={<div />}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
>
|
||||
<ActionButton size="l" className={cn(open && 'bg-state-base-hover')}>
|
||||
<span aria-hidden className="i-ri-equalizer-2-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement={placement || 'bottom-end'}
|
||||
sideOffset={4}
|
||||
popupClassName="w-[224px]"
|
||||
>
|
||||
<div className="px-3 py-1.5 system-md-regular text-text-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grow">{t('theme.theme', { ns: 'common' })}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<Divider type="horizontal" className="my-0" />
|
||||
<div className="p-1">
|
||||
{data?.privacy_policy && (
|
||||
<a href={data.privacy_policy} target="_blank" className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">
|
||||
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
onClick={() => {
|
||||
handleTrigger()
|
||||
setShow(true)
|
||||
}}
|
||||
className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('userProfile.about', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={handleLogout}
|
||||
className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
{t('userProfile.logout', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
{data?.privacy_policy && (
|
||||
<DropdownMenuLinkItem
|
||||
className="px-3 system-md-regular"
|
||||
href={data.privacy_policy}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span className="grow">{t('chat.privacyPolicyMiddle', { ns: 'share' })}</span>
|
||||
</DropdownMenuLinkItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="px-3 system-md-regular"
|
||||
onClick={handleOpenInfoModal}
|
||||
>
|
||||
{t('userProfile.about', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
|
||||
<DropdownMenuItem
|
||||
className="px-3 system-md-regular"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t('userProfile.logout', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{show && (
|
||||
<InfoModal
|
||||
isShow={show}
|
||||
|
||||
@ -2,6 +2,82 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OperationDropdown from '../operation-dropdown'
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ isOpen: open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
render,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
onClick?: React.MouseEventHandler<HTMLElement>
|
||||
}) => {
|
||||
const { isOpen, setOpen } = useDropdownMenuContext()
|
||||
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
onClick?.(e)
|
||||
setOpen(!isOpen)
|
||||
}
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { 'data-testid': 'dropdown-trigger', 'onClick': handleClick } as Record<string, unknown>, children)
|
||||
|
||||
return <button data-testid="dropdown-trigger" onClick={handleClick}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
className,
|
||||
popupClassName,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
}) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
return isOpen ? <div data-testid="dropdown-content" className={[className, popupClassName].filter(Boolean).join(' ')}>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-item"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
const defaultProps = {
|
||||
onEdit: vi.fn(),
|
||||
@ -16,7 +92,7 @@ describe('OperationDropdown', () => {
|
||||
|
||||
it('should render trigger button with more icon', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
const button = document.querySelector('button')
|
||||
const button = screen.getByTestId('dropdown-trigger')
|
||||
expect(button).toBeInTheDocument()
|
||||
const svg = button?.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
@ -39,37 +115,27 @@ describe('OperationDropdown', () => {
|
||||
it('should open dropdown when trigger is clicked', async () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
|
||||
// Dropdown content should be rendered
|
||||
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText('tools.mcp.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.mcp.operation.remove')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onOpenChange when opened', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should close dropdown when trigger is clicked again', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -78,62 +144,38 @@ describe('OperationDropdown', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onEdit={onEdit} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByText('tools.mcp.operation.edit'))
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onRemove when remove option is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onRemove={onRemove} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
fireEvent.click(screen.getByText('tools.mcp.operation.remove'))
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close dropdown after edit is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const editOption = screen.getByText('tools.mcp.operation.edit')
|
||||
fireEvent.click(editOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
onOpenChange.mockClear()
|
||||
fireEvent.click(screen.getByText('tools.mcp.operation.edit'))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should close dropdown after remove is clicked', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<OperationDropdown {...defaultProps} onOpenChange={onOpenChange} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
onOpenChange.mockClear()
|
||||
|
||||
const removeOption = screen.getByText('tools.mcp.operation.remove')
|
||||
fireEvent.click(removeOption)
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
onOpenChange.mockClear()
|
||||
fireEvent.click(screen.getByText('tools.mcp.operation.remove'))
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -141,39 +183,25 @@ describe('OperationDropdown', () => {
|
||||
it('should have correct dropdown width', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('.w-\\[160px\\]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
const dropdown = document.querySelector('.w-\\[160px\\]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rounded-xl on dropdown', () => {
|
||||
it('should render dropdown content through the shared popup shell', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dropdown = document.querySelector('[class*="rounded-xl"][class*="border"]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
expect(screen.getByTestId('dropdown-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show destructive hover style on remove option', () => {
|
||||
it('should apply destructive highlighted styles on remove option', () => {
|
||||
render(<OperationDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = document.querySelector('button')
|
||||
if (trigger) {
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// The text is in a div, and the hover style is on the parent div with group class
|
||||
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
|
||||
const removeOptionContainer = removeOptionText.closest('.group')
|
||||
expect(removeOptionContainer).toHaveClass('hover:bg-state-destructive-hover')
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('dropdown-trigger'))
|
||||
const removeOptionText = screen.getByText('tools.mcp.operation.remove')
|
||||
const removeOptionContainer = removeOptionText.closest('button')
|
||||
expect(removeOptionContainer).toHaveClass('data-highlighted:bg-state-destructive-hover')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -7,14 +7,15 @@ import {
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type Props = {
|
||||
inCard?: boolean
|
||||
@ -30,60 +31,37 @@ const OperationDropdown: FC<Props> = ({
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
onOpenChange?.(v)
|
||||
}, [doSetOpen])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: !inCard ? -12 : 0,
|
||||
crossAxis: !inCard ? 36 : 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div>
|
||||
<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs">
|
||||
<div
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onEdit()
|
||||
handleTrigger()
|
||||
}}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div className="ml-2 system-md-regular text-text-secondary">{t('mcp.operation.edit', { ns: 'tools' })}</div>
|
||||
</div>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center rounded-lg px-3 py-1.5 hover:bg-state-destructive-hover"
|
||||
onClick={() => {
|
||||
onRemove()
|
||||
handleTrigger()
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 text-text-tertiary group-hover:text-text-destructive-secondary" />
|
||||
<div className="ml-2 system-md-regular text-text-secondary group-hover:text-text-destructive">{t('mcp.operation.remove', { ns: 'tools' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={<ActionButton size={inCard ? 'l' : 'm'} className={cn(open && 'bg-state-base-hover')} />}
|
||||
>
|
||||
<RiMoreFill className={cn('h-4 w-4', inCard && 'h-5 w-5')} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[160px]"
|
||||
>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<RiEditLine className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<div className="ml-2 system-md-regular text-text-secondary">{t('mcp.operation.edit', { ns: 'tools' })}</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="data-highlighted:bg-state-destructive-hover data-highlighted:text-text-destructive"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 shrink-0 text-inherit" />
|
||||
<div className="ml-2 system-md-regular text-inherit">{t('mcp.operation.remove', { ns: 'tools' })}</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDownloadPlugin } from '@/service/use-plugins'
|
||||
import OperationDropdown from '../action'
|
||||
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockRemoveQueries = vi.fn()
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({
|
||||
theme: 'light',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useDownloadPlugin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: (path: string) => `https://marketplace.example${path}`,
|
||||
}))
|
||||
|
||||
const createQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderComponent = (props?: Partial<ComponentProps<typeof OperationDropdown>>) => {
|
||||
const queryClient = createQueryClient()
|
||||
vi.spyOn(queryClient, 'removeQueries').mockImplementation(((...args) => {
|
||||
return mockRemoveQueries(...args)
|
||||
}) as typeof queryClient.removeQueries)
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OperationDropdown
|
||||
open={false}
|
||||
onOpenChange={vi.fn()}
|
||||
author="langgenius"
|
||||
name="test-plugin"
|
||||
version="1.0.0"
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('OperationDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
|
||||
data: enabled ? null : null,
|
||||
isLoading: false,
|
||||
}) as unknown as ReturnType<typeof useDownloadPlugin>)
|
||||
})
|
||||
|
||||
it('should render download and view details actions when opened', async () => {
|
||||
renderComponent({ open: true })
|
||||
|
||||
expect(screen.getByText('common.operation.download')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.viewDetails')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request a download when download is clicked', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
renderComponent({ open: true, onOpenChange })
|
||||
|
||||
await userEvent.setup().click(screen.getByText('common.operation.download'))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(mockRemoveQueries).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip download when already loading', async () => {
|
||||
vi.mocked(useDownloadPlugin).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useDownloadPlugin>)
|
||||
|
||||
renderComponent({ open: true })
|
||||
|
||||
await userEvent.setup().click(screen.getByText('common.operation.download'))
|
||||
|
||||
expect(mockRemoveQueries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should download the blob when the hook returns data', async () => {
|
||||
vi.mocked(useDownloadPlugin).mockImplementation((_, enabled) => ({
|
||||
data: enabled ? new Blob(['plugin zip'], { type: 'application/zip' }) : null,
|
||||
isLoading: false,
|
||||
}) as unknown as ReturnType<typeof useDownloadPlugin>)
|
||||
|
||||
renderComponent({ open: true })
|
||||
|
||||
await userEvent.setup().click(screen.getByText('common.operation.download'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'langgenius-test-plugin_1.0.0.zip',
|
||||
})
|
||||
})
|
||||
expect(mockRemoveQueries).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should link to the marketplace detail page', () => {
|
||||
renderComponent({ open: true })
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'common.operation.viewDetails' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://marketplace.example/plugins/langgenius/test-plugin',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,19 +1,19 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
// import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useDownloadPlugin } from '@/service/use-plugins'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
@ -36,16 +36,10 @@ const OperationDropdown: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
onOpenChange(v)
|
||||
openRef.current = v
|
||||
const setOpen = useCallback((value: boolean) => {
|
||||
onOpenChange(value)
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const [needDownload, setNeedDownload] = useState(false)
|
||||
const downloadInfo = useMemo(() => ({
|
||||
organization: author,
|
||||
@ -56,12 +50,13 @@ const OperationDropdown: FC<Props> = ({
|
||||
const handleDownload = useCallback(() => {
|
||||
if (isLoading)
|
||||
return
|
||||
setOpen(false)
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
|
||||
exact: true,
|
||||
})
|
||||
setNeedDownload(true)
|
||||
}, [downloadInfo, isLoading, queryClient])
|
||||
}, [downloadInfo, isLoading, queryClient, setOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!needDownload || !blob)
|
||||
@ -75,27 +70,33 @@ const OperationDropdown: FC<Props> = ({
|
||||
})
|
||||
}, [author, blob, downloadInfo, name, needDownload, queryClient, version])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 0,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className="h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-components-button-secondary-accent-text" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-9999">
|
||||
<div className="min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
<div onClick={handleDownload} className="cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">{t('operation.download', { ns: 'common' })}</div>
|
||||
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="block cursor-pointer rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover">{t('operation.viewDetails', { ns: 'common' })}</a>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="min-w-[176px]"
|
||||
>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={handleDownload}>
|
||||
{t('operation.download', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLinkItem
|
||||
className="system-md-regular"
|
||||
href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('operation.viewDetails', { ns: 'common' })}
|
||||
</DropdownMenuLinkItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import type { TriggerOption } from '../test-run-menu'
|
||||
import { fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { TriggerType } from '../test-run-menu'
|
||||
import {
|
||||
getNormalizedShortcutKey,
|
||||
@ -10,6 +10,33 @@ import {
|
||||
useShortcutMenu,
|
||||
} from '../test-run-menu-helpers'
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => (
|
||||
<button type="button" className={className} onClick={onClick}>{children}</button>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
}))
|
||||
|
||||
@ -5,25 +5,62 @@ import { act } from 'react'
|
||||
import * as React from 'react'
|
||||
import TestRunMenu, { TriggerType } from '../test-run-menu'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => <div>{children}</div>,
|
||||
}))
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
render,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
render?: React.ReactElement
|
||||
}) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuLabel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
|
||||
DropdownMenuGroupLabel: ({ children, className }: { children: React.ReactNode, className?: string }) => <div className={className}>{children}</div>,
|
||||
DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />,
|
||||
DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement>, className?: string }) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../shortcuts-name', () => ({
|
||||
default: ({ keys }: { keys: string[] }) => <span>{keys.join('+')}</span>,
|
||||
@ -95,10 +132,11 @@ describe('TestRunMenu', () => {
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle via ref' }))
|
||||
})
|
||||
expect(screen.getByText('~')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: '0' })
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'run-all' }))
|
||||
expect(screen.getByText('~')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore disabled options in the rendered menu', async () => {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
isValidElement,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
export type ShortcutMapping = {
|
||||
@ -27,9 +28,8 @@ export const OptionRow = ({
|
||||
onSelect: (option: TriggerOption) => void
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className="flex cursor-pointer items-center rounded-lg px-3 py-1.5 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
<DropdownMenuItem
|
||||
className="h-auto px-3 py-1.5 system-md-regular"
|
||||
onClick={() => onSelect(option)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
@ -41,7 +41,7 @@ export const OptionRow = ({
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ShortcutMapping } from './test-run-menu-helpers'
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
|
||||
|
||||
export enum TriggerType {
|
||||
@ -127,7 +127,7 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
}), [hasSingleEnabledOption, runSoleOption])
|
||||
|
||||
const renderOption = (option: TriggerOption) => {
|
||||
return <OptionRow option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
|
||||
return <OptionRow key={option.id} option={option} shortcutKey={shortcutKeyById.get(option.id)} onSelect={handleSelect} />
|
||||
}
|
||||
|
||||
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(() => getMenuVisibility(options), [options])
|
||||
@ -141,27 +141,28 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={{ mainAxis: 8, crossAxis: -4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
<div className="w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
|
||||
<div className="mb-2 px-3 pt-2 text-sm font-medium text-text-primary">
|
||||
<DropdownMenuTrigger render={<div style={{ userSelect: 'none' }} />}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={8}
|
||||
alignOffset={-4}
|
||||
popupClassName="w-[284px] p-1"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary">
|
||||
{t('common.chooseStartNodeToRun', { ns: 'workflow' })}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<div>
|
||||
{hasUserInput && renderOption(options.userInput!)}
|
||||
|
||||
{(hasTriggers || hasRunAll) && hasUserInput && (
|
||||
<div className="mx-3 my-1 h-px bg-divider-subtle" />
|
||||
<DropdownMenuSeparator className="mx-3" />
|
||||
)}
|
||||
|
||||
{hasRunAll && renderOption(options.runAll!)}
|
||||
@ -170,9 +171,9 @@ const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
.filter(trigger => trigger.enabled !== false)
|
||||
.map(trigger => renderOption(trigger))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Operator from '../operator'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, render }: { children: ReactNode, render?: ReactNode }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
if (render)
|
||||
return <div onClick={() => setOpen(!open)}>{children}</div>
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/ui/button', () => ({
|
||||
Button: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<button type="button" className={className}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector', () => ({
|
||||
default: ({ trigger, onSelect }: { trigger: ((open: boolean) => ReactNode) | ReactNode, onSelect: (type: BlockEnum) => void }) => (
|
||||
<div>
|
||||
{typeof trigger === 'function' ? trigger(false) : trigger}
|
||||
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>select-http</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/hooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useAvailableBlocks: vi.fn(),
|
||||
useNodesInteractions: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks)
|
||||
const mockUseNodesInteractions = vi.mocked(useNodesInteractions)
|
||||
|
||||
const mockHandleNodeChange = vi.fn()
|
||||
const mockHandleNodeDelete = vi.fn()
|
||||
const mockHandleNodeDisconnect = vi.fn()
|
||||
|
||||
const defaultNodeData = {
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code Node',
|
||||
} as CommonNodeType
|
||||
|
||||
const TestHarness = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Operator
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
data={defaultNodeData}
|
||||
nodeId="node-1"
|
||||
sourceHandle="source"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NextStep operator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseAvailableBlocks.mockReturnValue({
|
||||
availablePrevBlocks: [BlockEnum.HttpRequest],
|
||||
availableNextBlocks: [BlockEnum.HttpRequest],
|
||||
getAvailableBlocks: vi.fn(),
|
||||
} as ReturnType<typeof useAvailableBlocks>)
|
||||
mockUseNodesInteractions.mockReturnValue({
|
||||
handleNodeChange: mockHandleNodeChange,
|
||||
handleNodeDelete: mockHandleNodeDelete,
|
||||
handleNodeDisconnect: mockHandleNodeDisconnect,
|
||||
} as unknown as ReturnType<typeof useNodesInteractions>)
|
||||
})
|
||||
|
||||
it('opens the menu and keeps the change action available', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TestHarness />)
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
|
||||
expect(screen.getByText('workflow.panel.change')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.disconnect')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes the next-step block through the nested selector trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TestHarness />)
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
await user.click(screen.getByText('select-http'))
|
||||
|
||||
expect(mockHandleNodeChange).toHaveBeenCalledWith('node-1', BlockEnum.HttpRequest, 'source', undefined)
|
||||
})
|
||||
|
||||
it('disconnects and deletes the next step from the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TestHarness />)
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
await user.click(screen.getByText('workflow.common.disconnect'))
|
||||
expect(mockHandleNodeDisconnect).toHaveBeenCalledWith('node-1')
|
||||
expect(screen.queryByText('workflow.common.disconnect')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getAllByRole('button')[0]!)
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
expect(mockHandleNodeDelete).toHaveBeenCalledWith('node-1')
|
||||
})
|
||||
})
|
||||
@ -2,18 +2,17 @@ import type {
|
||||
CommonNodeType,
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { intersection } from 'es-toolkit/array'
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
@ -86,18 +85,21 @@ const Operator = ({
|
||||
} = useNodesInteractions()
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4, crossAxis: -4 }}
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => onOpenChange(!open)}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<Button className="h-6 w-6 p-0">
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={-4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur system-md-regular text-text-secondary shadow-lg">
|
||||
<div className="p-1">
|
||||
<ChangeItem
|
||||
@ -107,7 +109,10 @@ const Operator = ({
|
||||
/>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
|
||||
onClick={() => handleNodeDisconnect(nodeId)}
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
handleNodeDisconnect(nodeId)
|
||||
}}
|
||||
>
|
||||
{t('common.disconnect', { ns: 'workflow' })}
|
||||
</div>
|
||||
@ -115,14 +120,17 @@ const Operator = ({
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover"
|
||||
onClick={() => handleNodeDelete(nodeId)}
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
handleNodeDelete(nodeId)
|
||||
}}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -70,7 +70,11 @@ const createQueryResult = <T,>(data: T): UseQueryResult<T, Error> => ({
|
||||
promise: Promise.resolve(data),
|
||||
} as UseQueryResult<T, Error>)
|
||||
|
||||
const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boolean) => void) =>
|
||||
const renderComponent = (
|
||||
showHelpLink: boolean = true,
|
||||
onOpenChange?: (open: boolean) => void,
|
||||
offset?: { mainAxis: number, crossAxis: number } | number,
|
||||
) =>
|
||||
renderWorkflowFlowComponent(
|
||||
<PanelOperator
|
||||
id="node-1"
|
||||
@ -80,6 +84,7 @@ const renderComponent = (showHelpLink: boolean = true, onOpenChange?: (open: boo
|
||||
type: BlockEnum.Code,
|
||||
}}
|
||||
triggerClassName="panel-operator-trigger"
|
||||
offset={offset}
|
||||
onOpenChange={onOpenChange}
|
||||
showHelpLink={showHelpLink}
|
||||
/>,
|
||||
@ -158,5 +163,15 @@ describe('PanelOperator', () => {
|
||||
expect(screen.queryByText('workflow.panel.helpLink')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still open the popup when using a numeric offset and no open-change callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderComponent(true, undefined, 0)
|
||||
|
||||
await user.click(container.querySelector('.panel-operator-trigger') as HTMLElement)
|
||||
|
||||
expect(screen.getByText('workflow.panel.runThisStep')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,23 +1,22 @@
|
||||
import type { OffsetOptions } from '@floating-ui/react'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import PanelOperatorPopup from './panel-operator-popup'
|
||||
|
||||
type PanelOperatorProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
triggerClassName?: string
|
||||
offset?: OffsetOptions
|
||||
offset?: OffsetOptions | number
|
||||
onOpenChange?: (open: boolean) => void
|
||||
inNode?: boolean
|
||||
showHelpLink?: boolean
|
||||
@ -34,6 +33,14 @@ const PanelOperator = ({
|
||||
showHelpLink = true,
|
||||
}: PanelOperatorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const sideOffset = typeof offset === 'number'
|
||||
? offset
|
||||
: typeof offset === 'object' && offset && 'mainAxis' in offset && typeof offset.mainAxis === 'number'
|
||||
? offset.mainAxis
|
||||
: 4
|
||||
const alignOffset = typeof offset === 'object' && offset && 'crossAxis' in offset && typeof offset.crossAxis === 'number'
|
||||
? offset.crossAxis
|
||||
: 0
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
@ -43,13 +50,11 @@ const PanelOperator = ({
|
||||
}, [onOpenChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={offset}
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
|
||||
<DropdownMenuTrigger render={<div />}>
|
||||
<div
|
||||
className={`
|
||||
flex h-6 w-6 cursor-pointer items-center justify-center rounded-md
|
||||
@ -58,18 +63,23 @@ const PanelOperator = ({
|
||||
${triggerClassName}
|
||||
`}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<PanelOperatorPopup
|
||||
id={id}
|
||||
data={data}
|
||||
onClosePopup={() => setOpen(false)}
|
||||
onClosePopup={() => handleOpenChange(false)}
|
||||
showHelpLink={showHelpLink}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,75 @@ import { VarType } from '@/app/components/workflow/types'
|
||||
import { WriteMode } from '../../types'
|
||||
import OperationSelector from '../operation-selector'
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuGroupLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('assigner/operation-selector', () => {
|
||||
it('shows numeric write modes and emits the selected operation', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@ -3,18 +3,17 @@ import type { WriteMode } from '../types'
|
||||
import type { Item } from '../utils'
|
||||
import type { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { getOperationItems, isOperationItem } from '../utils'
|
||||
|
||||
type OperationSelectorProps = {
|
||||
@ -49,65 +48,57 @@ const OperationSelector: FC<OperationSelectorProps> = ({
|
||||
const selectedItem = items.find(item => item.value === value)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && setOpen(v => !v)}
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn('flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', className)}
|
||||
>
|
||||
<div
|
||||
className={cn('flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1', disabled ? 'cursor-not-allowed bg-components-input-bg-disabled!' : 'cursor-pointer hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', className)}
|
||||
>
|
||||
<div className="flex items-center p-1">
|
||||
<span
|
||||
className={`truncate overflow-hidden system-sm-regular text-ellipsis
|
||||
<div className="flex items-center p-1">
|
||||
<span
|
||||
className={`truncate overflow-hidden system-sm-regular text-ellipsis
|
||||
${selectedItem ? 'text-components-input-text-filled' : 'text-components-input-text-disabled'}`}
|
||||
>
|
||||
{selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-quaternary ${disabled && 'text-components-input-text-placeholder'} ${open && 'text-text-secondary'}`} />
|
||||
>
|
||||
{selectedItem && isOperationItem(selectedItem) ? t(`nodes.assigner.operations.${selectedItem.name}`, { ns: 'workflow' }) : t('nodes.assigner.operations.title', { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary', disabled && 'text-components-input-text-placeholder', open && 'text-text-secondary')} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
|
||||
<div className="flex w-[140px] flex-col items-start rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="flex flex-col items-start self-stretch p-1">
|
||||
<div className="flex items-start self-stretch px-3 pt-1 pb-0.5">
|
||||
<div className="flex grow system-xs-medium-uppercase text-text-tertiary">{t('nodes.assigner.operations.title', { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
{items.map(item => (
|
||||
!isOperationItem(item)
|
||||
? (
|
||||
<Divider key="divider" className="my-1" />
|
||||
)
|
||||
: (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn('flex items-center gap-1 self-stretch rounded-lg px-2 py-1', 'cursor-pointer hover:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-5 grow items-center gap-1 px-1">
|
||||
<span className="flex grow system-sm-medium text-text-secondary">{t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
{item.value === value && (
|
||||
<div className="flex items-center justify-center">
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName={cn('w-[140px]', popupClassName)}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t('nodes.assigner.operations.title', { ns: 'workflow' })}</DropdownMenuLabel>
|
||||
{items.map(item => (
|
||||
!isOperationItem(item)
|
||||
? (
|
||||
<DropdownMenuSeparator key="divider" />
|
||||
)
|
||||
: (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
className="gap-1 px-2 py-1"
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<div className="flex min-h-5 grow items-center gap-1 px-1">
|
||||
<span className="flex grow system-sm-medium text-text-secondary">{t(`nodes.assigner.operations.${item.name}`, { ns: 'workflow' })}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{item.value === value && (
|
||||
<div className="flex items-center justify-center">
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -110,6 +110,8 @@ describe('NoteNode', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('workflow.nodes.note.editor.small').closest('.nodrag.nopan.nowheel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the toolbar for temporary notes', () => {
|
||||
|
||||
@ -95,7 +95,7 @@ const NoteNode = ({
|
||||
</div>
|
||||
{
|
||||
data.selected && !data._isTempNode && (
|
||||
<div className="absolute top-[-41px] left-1/2 -translate-x-1/2">
|
||||
<div className="pointer-events-auto absolute top-[-41px] left-1/2 z-40 -translate-x-1/2">
|
||||
<NoteEditorToolbar
|
||||
theme={theme}
|
||||
onThemeChange={handleThemeChange}
|
||||
|
||||
@ -86,7 +86,7 @@ describe('NoteEditor Toolbar', () => {
|
||||
|
||||
expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet)
|
||||
|
||||
fireEvent.click(container.querySelectorAll('[data-state="closed"]')[container.querySelectorAll('[data-state="closed"]').length - 1] as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(screen.getByText('workflow.common.copy'))
|
||||
|
||||
expect(onCopy).toHaveBeenCalledTimes(1)
|
||||
|
||||
@ -1,13 +1,132 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type {
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Operator from '../operator'
|
||||
|
||||
type DropdownTriggerRenderProps = {
|
||||
'className'?: string
|
||||
'role'?: string
|
||||
'aria-label'?: string
|
||||
'onMouseDown'?: MouseEventHandler<HTMLDivElement>
|
||||
'onClick'?: MouseEventHandler<HTMLDivElement>
|
||||
}
|
||||
|
||||
type DropdownTriggerProps = {
|
||||
'children': ReactNode
|
||||
'className'?: string
|
||||
'render'?: ReactElement<DropdownTriggerRenderProps>
|
||||
'onMouseDown'?: MouseEventHandler<HTMLDivElement>
|
||||
'onClick'?: MouseEventHandler<HTMLDivElement>
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
render,
|
||||
onMouseDown,
|
||||
onClick,
|
||||
'aria-label': ariaLabel,
|
||||
}: DropdownTriggerProps) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
if (render) {
|
||||
const handleMouseDown = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const baseUiEvent = event as MouseEvent<HTMLDivElement> & { preventBaseUIHandler?: () => void }
|
||||
baseUiEvent.preventBaseUIHandler = vi.fn()
|
||||
onMouseDown?.(baseUiEvent)
|
||||
render.props.onMouseDown?.(event)
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||
onClick?.(event)
|
||||
render.props.onClick?.(event)
|
||||
if (!onMouseDown)
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={render.props.role ?? 'button'}
|
||||
aria-label={render.props['aria-label'] ?? ariaLabel}
|
||||
className={render.props.className ?? className}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />,
|
||||
}
|
||||
})
|
||||
|
||||
const renderOperator = (showAuthor = false) => {
|
||||
const onCopy = vi.fn()
|
||||
const onDuplicate = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onShowAuthorChange = vi.fn()
|
||||
|
||||
const renderResult = render(
|
||||
render(
|
||||
<Operator
|
||||
onCopy={onCopy}
|
||||
onDuplicate={onDuplicate}
|
||||
@ -18,7 +137,6 @@ const renderOperator = (showAuthor = false) => {
|
||||
)
|
||||
|
||||
return {
|
||||
...renderResult,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@ -27,41 +145,35 @@ const renderOperator = (showAuthor = false) => {
|
||||
}
|
||||
|
||||
describe('NoteEditor Toolbar Operator', () => {
|
||||
it('should trigger copy, duplicate, and delete from the opened menu', () => {
|
||||
it('triggers copy, duplicate, and delete from the opened menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const {
|
||||
container,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
} = renderOperator()
|
||||
|
||||
const trigger = container.querySelector('[data-state="closed"]') as HTMLElement
|
||||
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByText('workflow.common.copy'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(screen.getByText('workflow.common.copy'))
|
||||
expect(onCopy).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByText('workflow.common.duplicate'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(screen.getByText('workflow.common.duplicate'))
|
||||
expect(onDuplicate).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should forward the switch state through onShowAuthorChange', () => {
|
||||
const {
|
||||
container,
|
||||
onShowAuthorChange,
|
||||
} = renderOperator(true)
|
||||
it('keeps the menu open when toggling show author', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onShowAuthorChange } = renderOperator(true)
|
||||
|
||||
fireEvent.click(container.querySelector('[data-state="closed"]') as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onShowAuthorChange).toHaveBeenCalledWith(false)
|
||||
expect(screen.getByText('workflow.nodes.note.editor.showAuthor')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,7 +18,11 @@ const Toolbar = ({
|
||||
onShowAuthorChange,
|
||||
}: ToolbarProps) => {
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-sm">
|
||||
<div
|
||||
className="nodrag nopan nowheel inline-flex items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-sm"
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
theme={theme}
|
||||
onThemeChange={onThemeChange}
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
|
||||
export type OperatorProps = {
|
||||
@ -31,47 +32,57 @@ const Operator = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
open && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={<div />}
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
open && 'bg-state-base-hover text-text-secondary',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
;(event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.()
|
||||
setOpen(prev => !prev)
|
||||
}}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="min-w-[192px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl">
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
<DropdownMenuItem
|
||||
className="justify-between rounded-md px-3 text-sm text-text-secondary"
|
||||
onClick={() => {
|
||||
onCopy()
|
||||
setOpen(false)
|
||||
onCopy()
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="justify-between rounded-md px-3 text-sm text-text-secondary"
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
setOpen(false)
|
||||
onDuplicate()
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
@ -85,22 +96,23 @@ const Operator = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-md px-3 text-sm text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="justify-between rounded-md px-3 text-sm text-text-secondary"
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
setOpen(false)
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,309 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import MoreActions from '../more-actions'
|
||||
|
||||
const mockToPng = vi.fn()
|
||||
const mockToJpeg = vi.fn()
|
||||
const mockToSvg = vi.fn()
|
||||
const mockDownloadUrl = vi.fn()
|
||||
const mockSetViewport = vi.fn()
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
const {
|
||||
mockAppStoreState,
|
||||
mockWorkflowState,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAppStoreState: {
|
||||
appSidebarExpand: 'collapse',
|
||||
},
|
||||
mockWorkflowState: {
|
||||
knowledgeName: '',
|
||||
appName: 'Demo App',
|
||||
maximizeCanvas: false,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button type="button" className={className} onClick={() => setOpen(!open)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
className?: string
|
||||
}) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
DropdownMenuSeparator: ({ className }: { className?: string }) => <div className={className} data-testid="dropdown-separator" />,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('html-to-image', () => ({
|
||||
toPng: (...args: unknown[]) => mockToPng(...args),
|
||||
toJpeg: (...args: unknown[]) => mockToJpeg(...args),
|
||||
toSvg: (...args: unknown[]) => mockToSvg(...args),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
getNodesBounds: () => ({ x: 0, y: 0, width: 240, height: 120 }),
|
||||
useReactFlow: () => ({
|
||||
getNodes: () => [{ id: 'node-1' }],
|
||||
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: typeof mockAppStoreState) => unknown) => selector(mockAppStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../tip-popup', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
|
||||
default: ({ title, onCancel }: { title: string, onCancel: () => void }) => (
|
||||
<div data-testid="image-preview">
|
||||
<span>{title}</span>
|
||||
<button type="button" onClick={onCancel}>close-preview</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('MoreActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockToPng.mockResolvedValue('data:image/png;base64,current')
|
||||
mockToJpeg.mockResolvedValue('data:image/jpeg;base64,current')
|
||||
mockToSvg.mockResolvedValue('data:image/svg+xml;base64,current')
|
||||
mockAppStoreState.appSidebarExpand = 'collapse'
|
||||
mockWorkflowState.knowledgeName = ''
|
||||
mockWorkflowState.appName = 'Demo App'
|
||||
mockWorkflowState.maximizeCanvas = false
|
||||
|
||||
document.body.innerHTML = ''
|
||||
const viewport = document.createElement('div')
|
||||
viewport.className = 'react-flow__viewport'
|
||||
document.body.appendChild(viewport)
|
||||
})
|
||||
|
||||
it('opens the menu and exports the current view as png', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToPng).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({
|
||||
url: 'data:image/png;base64,current',
|
||||
fileName: 'Demo App.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not open the menu when the workflow is read only', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.queryByText('workflow.common.exportImage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a preview when exporting the whole workflow', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.png')
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(mockSetViewport).toHaveBeenCalledTimes(2)
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({
|
||||
url: 'data:image/png;base64,current',
|
||||
fileName: 'Demo App-whole-workflow.png',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['workflow.common.exportJPEG', mockToJpeg, 'Demo App.jpeg'],
|
||||
['workflow.common.exportSVG', mockToSvg, 'Demo App.svg'],
|
||||
])('exports the current view with %s', async (label, exporter, fileName) => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getAllByText(label)[0]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exporter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({
|
||||
url: expect.any(String),
|
||||
fileName,
|
||||
})
|
||||
})
|
||||
|
||||
it('exports the whole workflow as svg when the canvas is maximized', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockWorkflowState.maximizeCanvas = true
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getAllByText('workflow.common.exportSVG')[1]!)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(mockToSvg).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
expect(mockSetViewport).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByTestId('image-preview')).toHaveTextContent('Demo App-whole-workflow.svg')
|
||||
})
|
||||
|
||||
it('returns early when there is no app or knowledge name', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockWorkflowState.appName = ''
|
||||
mockWorkflowState.knowledgeName = ''
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
|
||||
|
||||
expect(mockToPng).not.toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns early when the viewport element is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
document.querySelector('.react-flow__viewport')?.remove()
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
|
||||
|
||||
expect(mockToPng).not.toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns early when the workflow becomes read only before exporting', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
mockGetNodesReadOnly.mockReturnValue(true)
|
||||
await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!)
|
||||
|
||||
expect(mockToJpeg).not.toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs export failures and lets the preview close', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockToJpeg.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
render(<MoreActions />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('Export image failed:', expect.any(Error))
|
||||
})
|
||||
expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
|
||||
|
||||
mockToPng.mockResolvedValueOnce('data:image/png;base64,current')
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('close-preview'))
|
||||
expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,197 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import ZoomInOut from '../zoom-in-out'
|
||||
|
||||
const {
|
||||
mockZoomIn,
|
||||
mockZoomOut,
|
||||
mockZoomTo,
|
||||
mockFitView,
|
||||
mockViewport,
|
||||
mockHandleSyncWorkflowDraft,
|
||||
mockToggleMiniMap,
|
||||
mockToggleUserComments,
|
||||
mockToggleUserCursors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockZoomIn: vi.fn(),
|
||||
mockZoomOut: vi.fn(),
|
||||
mockZoomTo: vi.fn(),
|
||||
mockFitView: vi.fn(),
|
||||
mockViewport: { zoom: 1 },
|
||||
mockHandleSyncWorkflowDraft: vi.fn(),
|
||||
mockToggleMiniMap: vi.fn(),
|
||||
mockToggleUserComments: vi.fn(),
|
||||
mockToggleUserCursors: vi.fn(),
|
||||
}))
|
||||
|
||||
let workflowReadOnly = false
|
||||
let collaborationEnabled = true
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
zoomIn: mockZoomIn,
|
||||
zoomOut: mockZoomOut,
|
||||
zoomTo: mockZoomTo,
|
||||
fitView: mockFitView,
|
||||
}),
|
||||
useViewport: () => mockViewport,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
useWorkflowReadOnly: () => ({
|
||||
workflowReadOnly,
|
||||
getWorkflowReadOnly: () => workflowReadOnly,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_collaboration_mode: collaborationEnabled,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../tip-popup', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const getZoomControls = () => {
|
||||
const label = Array.from(document.querySelectorAll('button')).find((element) => {
|
||||
return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]')
|
||||
})
|
||||
const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line')
|
||||
const zoomInIcon = document.querySelector('.i-ri-zoom-in-line')
|
||||
|
||||
if (!label || !zoomOutIcon || !zoomInIcon)
|
||||
throw new Error('Missing zoom controls')
|
||||
|
||||
return {
|
||||
zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement,
|
||||
label,
|
||||
zoomInTrigger: zoomInIcon.parentElement as HTMLElement,
|
||||
}
|
||||
}
|
||||
|
||||
const openZoomMenu = () => {
|
||||
fireEvent.click(getZoomControls().label)
|
||||
return within(screen.getByRole('menu'))
|
||||
}
|
||||
|
||||
describe('workflow zoom controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockViewport.zoom = 1
|
||||
workflowReadOnly = false
|
||||
collaborationEnabled = true
|
||||
})
|
||||
|
||||
it('zooms out and zooms in when the viewport is within the supported range', () => {
|
||||
render(<ZoomInOut />)
|
||||
|
||||
const { zoomOutTrigger, zoomInTrigger } = getZoomControls()
|
||||
|
||||
fireEvent.click(zoomOutTrigger)
|
||||
fireEvent.click(zoomInTrigger)
|
||||
|
||||
expect(mockZoomOut).toHaveBeenCalledTimes(1)
|
||||
expect(mockZoomIn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('zooms to a preset value and syncs the draft', () => {
|
||||
render(<ZoomInOut />)
|
||||
|
||||
const menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText('50%'))
|
||||
|
||||
expect(mockZoomTo).toHaveBeenCalledWith(0.5)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['100%', 1],
|
||||
['200%', 2],
|
||||
])('zooms to %s and syncs the draft', (label, zoom) => {
|
||||
render(<ZoomInOut />)
|
||||
|
||||
const menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText(label))
|
||||
|
||||
expect(mockZoomTo).toHaveBeenCalledWith(zoom)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('toggles collaboration options without syncing the draft', () => {
|
||||
render(
|
||||
<ZoomInOut
|
||||
onToggleMiniMap={mockToggleMiniMap}
|
||||
onToggleUserComments={mockToggleUserComments}
|
||||
onToggleUserCursors={mockToggleUserCursors}
|
||||
/>,
|
||||
)
|
||||
|
||||
let menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText('workflow.operator.showMiniMap'))
|
||||
expect(mockToggleMiniMap).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
|
||||
menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText('workflow.operator.showUserComments'))
|
||||
expect(mockToggleUserComments).toHaveBeenCalledTimes(1)
|
||||
|
||||
menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText('workflow.operator.showUserCursors'))
|
||||
expect(mockToggleUserCursors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the show-user-comments action disabled in comment mode', () => {
|
||||
render(
|
||||
<ZoomInOut
|
||||
isCommentMode
|
||||
onToggleUserComments={mockToggleUserComments}
|
||||
/>,
|
||||
)
|
||||
|
||||
const menu = openZoomMenu()
|
||||
fireEvent.click(menu.getByText('workflow.operator.showUserComments'))
|
||||
|
||||
expect(mockToggleUserComments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open the menu when the workflow is read only', () => {
|
||||
workflowReadOnly = true
|
||||
render(<ZoomInOut />)
|
||||
|
||||
fireEvent.click(getZoomControls().label)
|
||||
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks inline zooming out at the minimum viewport scale', () => {
|
||||
mockViewport.zoom = 0.25
|
||||
render(<ZoomInOut />)
|
||||
|
||||
fireEvent.click(getZoomControls().zoomOutTrigger)
|
||||
expect(mockZoomOut).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks inline zooming in at the maximum viewport scale', () => {
|
||||
mockViewport.zoom = 2
|
||||
render(<ZoomInOut />)
|
||||
|
||||
fireEvent.click(getZoomControls().zoomInTrigger)
|
||||
expect(mockZoomIn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders collaboration menu entries only when collaboration is enabled', () => {
|
||||
collaborationEnabled = false
|
||||
render(<ZoomInOut />)
|
||||
|
||||
const menu = openZoomMenu()
|
||||
expect(menu.getByText('workflow.operator.showMiniMap')).toBeInTheDocument()
|
||||
expect(menu.queryByText('workflow.operator.showUserComments')).not.toBeInTheDocument()
|
||||
expect(menu.queryByText('workflow.operator.showUserCursors')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -14,10 +14,12 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import { useNodesReadOnly } from '../hooks'
|
||||
@ -37,6 +39,7 @@ const MoreActions: FC = () => {
|
||||
const { appSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
})))
|
||||
const isReadOnly = getNodesReadOnly()
|
||||
|
||||
const crossAxisOffset = useMemo(() => {
|
||||
if (maximizeCanvas)
|
||||
@ -161,93 +164,67 @@ const MoreActions: FC = () => {
|
||||
}
|
||||
}, [getNodesReadOnly, appName, reactFlow, knowledgeName])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}, [getNodesReadOnly])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: -200,
|
||||
crossAxis: crossAxisOffset,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isReadOnly) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
>
|
||||
<TipPopup title={t('common.moreActions', { ns: 'workflow' })}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</div>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<div className="min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
<RiExportLine className="h-3 w-3" />
|
||||
{t('common.exportImage', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
{t('common.currentView', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('png')}
|
||||
>
|
||||
{t('common.exportPNG', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('jpeg')}
|
||||
>
|
||||
{t('common.exportJPEG', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('svg')}
|
||||
>
|
||||
{t('common.exportSVG', { ns: 'workflow' })}
|
||||
</div>
|
||||
|
||||
<div className="border-border-divider mx-2 my-1 border-t" />
|
||||
|
||||
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
{t('common.currentWorkflow', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('png', true)}
|
||||
>
|
||||
{t('common.exportPNG', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('jpeg', true)}
|
||||
>
|
||||
{t('common.exportJPEG', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular hover:bg-state-base-hover"
|
||||
onClick={() => handleExportImage('svg', true)}
|
||||
>
|
||||
{t('common.exportSVG', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={-200}
|
||||
alignOffset={crossAxisOffset}
|
||||
popupClassName="min-w-[180px]"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
<RiExportLine className="h-3 w-3" />
|
||||
{t('common.exportImage', { ns: 'workflow' })}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
{t('common.currentView', { ns: 'workflow' })}
|
||||
</div>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png')}>
|
||||
{t('common.exportPNG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg')}>
|
||||
{t('common.exportJPEG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg')}>
|
||||
{t('common.exportSVG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator className="mx-2" />
|
||||
|
||||
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
|
||||
{t('common.currentWorkflow', { ns: 'workflow' })}
|
||||
</div>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png', true)}>
|
||||
{t('common.exportPNG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg', true)}>
|
||||
{t('common.exportJPEG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg', true)}>
|
||||
{t('common.exportSVG', { ns: 'workflow' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{previewUrl && (
|
||||
<ImagePreview
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiFullscreenLine,
|
||||
RiZoomInLine,
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -17,14 +10,14 @@ import {
|
||||
useReactFlow,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import Divider from '../../base/divider'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowReadOnly,
|
||||
@ -33,8 +26,6 @@ import ShortcutsName from '../shortcuts-name'
|
||||
import TipPopup from './tip-popup'
|
||||
|
||||
enum ZoomType {
|
||||
zoomIn = 'zoomIn',
|
||||
zoomOut = 'zoomOut',
|
||||
zoomToFit = 'zoomToFit',
|
||||
zoomTo25 = 'zoomTo25',
|
||||
zoomTo50 = 'zoomTo50',
|
||||
@ -81,7 +72,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
} = useWorkflowReadOnly()
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
const ZOOM_IN_OUT_OPTIONS = [
|
||||
const zoomOptions = [
|
||||
[
|
||||
{
|
||||
key: ZoomType.zoomTo200,
|
||||
@ -135,6 +126,8 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
setOpen(false)
|
||||
|
||||
if (type === ZoomType.zoomToFit)
|
||||
fitView()
|
||||
|
||||
@ -173,154 +166,134 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}, [getWorkflowReadOnly])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="top-start"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
<div className={`
|
||||
h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg
|
||||
p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]
|
||||
hover:bg-state-base-hover
|
||||
${workflowReadOnly && 'cursor-not-allowed! opacity-50'}
|
||||
`}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div className={`
|
||||
h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg
|
||||
p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]
|
||||
hover:bg-state-base-hover
|
||||
${workflowReadOnly && 'cursor-not-allowed! opacity-50'}
|
||||
`}
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-8 w-[98px] items-center justify-between rounded-lg',
|
||||
)}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<RiZoomOutLine className="h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div onClick={handleTrigger} className={cn('w-[34px] system-sm-medium text-text-tertiary hover:text-text-secondary')}>
|
||||
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
|
||||
%
|
||||
</div>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<RiZoomInLine className="h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[60]">
|
||||
<div className="w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
{
|
||||
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
|
||||
<Fragment key={i}>
|
||||
{
|
||||
i !== 0 && (
|
||||
<Divider className="m-0" />
|
||||
)
|
||||
}
|
||||
<div className="p-1">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={getWorkflowReadOnly()}
|
||||
className={cn(
|
||||
'flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary',
|
||||
open && 'bg-black/5 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
|
||||
%
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-start"
|
||||
sideOffset={4}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
{zoomOptions.map((options, groupIndex) => (
|
||||
<Fragment key={options[0]!.key}>
|
||||
{groupIndex !== 0 && (
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
)}
|
||||
<div className="p-1">
|
||||
{options.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.key}
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg py-1.5 pr-2 pl-3 system-md-regular text-text-secondary hover:bg-state-base-hover',
|
||||
option.key === ZoomType.toggleUserComments && isCommentMode && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
className="justify-between px-3 py-1.5 system-md-regular text-text-secondary"
|
||||
disabled={option.key === ZoomType.toggleUserComments && isCommentMode}
|
||||
onClick={() => handleZoom(option.key)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{option.key === ZoomType.toggleUserComments && showUserComments && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
{option.key === ZoomType.toggleUserComments && !showUserComments && (
|
||||
<div className="h-4 w-4" />
|
||||
<span aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
{option.key === ZoomType.toggleUserCursors && showUserCursors && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
{option.key === ZoomType.toggleUserCursors && !showUserCursors && (
|
||||
<div className="h-4 w-4" />
|
||||
<span aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
{option.key === ZoomType.toggleMiniMap && showMiniMap && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)}
|
||||
{option.key === ZoomType.toggleMiniMap && !showMiniMap && (
|
||||
<div className="h-4 w-4" />
|
||||
<span aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<RiFullscreenLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span aria-hidden className="i-ri-fullscreen-line h-4 w-4 text-text-tertiary" />
|
||||
)}
|
||||
{option.key !== ZoomType.toggleUserComments
|
||||
&& option.key !== ZoomType.toggleUserCursors
|
||||
&& option.key !== ZoomType.toggleMiniMap
|
||||
&& option.key !== ZoomType.zoomToFit && (
|
||||
<div className="h-4 w-4" />
|
||||
<span aria-hidden className="h-4 w-4" />
|
||||
)}
|
||||
<span>{option.text}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{
|
||||
option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)
|
||||
}
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DropdownMenu, DropdownMenuContent } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../../types'
|
||||
import MenuItem from '../menu-item'
|
||||
|
||||
@ -9,14 +10,18 @@ describe('MenuItem', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(
|
||||
<MenuItem
|
||||
item={{
|
||||
key: VersionHistoryContextMenuOptions.delete,
|
||||
name: 'Delete',
|
||||
}}
|
||||
isDestructive
|
||||
onClick={onClick}
|
||||
/>,
|
||||
<DropdownMenu open onOpenChange={vi.fn()}>
|
||||
<DropdownMenuContent>
|
||||
<MenuItem
|
||||
item={{
|
||||
key: VersionHistoryContextMenuOptions.delete,
|
||||
name: 'Delete',
|
||||
}}
|
||||
isDestructive
|
||||
onClick={onClick}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Delete'))
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
import MenuItem from './menu-item'
|
||||
import useContextMenu from './use-context-menu'
|
||||
@ -28,58 +27,44 @@ const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => {
|
||||
options,
|
||||
} = useContextMenu(props)
|
||||
|
||||
const handleClickTrigger = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
setOpen(v => !v)
|
||||
}, [setOpen])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<Button size="small" className="px-1" onClick={handleClickTrigger}>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<div className="flex w-[184px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
<div className="flex flex-col p-1">
|
||||
{
|
||||
options.map((option) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
item={option}
|
||||
onClick={handleClickMenuItem.bind(null, option.key)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isShowDelete && (
|
||||
<>
|
||||
<Divider type="horizontal" className="my-0 h-px bg-divider-subtle" />
|
||||
<div className="p-1">
|
||||
<MenuItem
|
||||
item={deleteOperation}
|
||||
isDestructive
|
||||
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button size="small" className="px-1" onClick={e => e.stopPropagation()} />}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[184px] shadow-shadow-shadow-5"
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
item={option}
|
||||
onClick={handleClickMenuItem.bind(null, option.key)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
{
|
||||
isShowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<MenuItem
|
||||
item={deleteOperation}
|
||||
isDestructive
|
||||
onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import type { FC } from 'react'
|
||||
import type { VersionHistoryContextMenuOptions } from '../../../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { DropdownMenuItem } from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type MenuItemProps = {
|
||||
item: {
|
||||
@ -18,23 +19,25 @@ const MenuItem: FC<MenuItemProps> = ({
|
||||
isDestructive = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
<DropdownMenuItem
|
||||
variant={isDestructive ? 'destructive' : 'default'}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between rounded-lg px-2 py-1.5',
|
||||
isDestructive ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover',
|
||||
'justify-between px-2 py-1.5',
|
||||
isDestructive && 'data-highlighted:bg-state-destructive-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick(item.key)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex-1 system-md-regular text-text-primary',
|
||||
isDestructive && 'hover:text-text-destructive',
|
||||
isDestructive && 'text-inherit',
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,52 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AgentLogNavMore from '../agent-log-nav-more'
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', async () => {
|
||||
const React = await import('react')
|
||||
const DropdownMenuContext = React.createContext<{ open: boolean, setOpen: (open: boolean) => void } | null>(null)
|
||||
|
||||
const useDropdownMenuContext = () => {
|
||||
const context = React.use(DropdownMenuContext)
|
||||
if (!context)
|
||||
throw new Error('DropdownMenu components must be wrapped in DropdownMenu')
|
||||
return context
|
||||
}
|
||||
|
||||
return {
|
||||
DropdownMenu: ({ children, open, onOpenChange }: { children: React.ReactNode, open: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
<DropdownMenuContext value={{ open, setOpen: onOpenChange ?? vi.fn() }}>
|
||||
<div>{children}</div>
|
||||
</DropdownMenuContext>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, render }: { children: React.ReactNode, render?: React.ReactElement }) => {
|
||||
const { open, setOpen } = useDropdownMenuContext()
|
||||
|
||||
if (render)
|
||||
return React.cloneElement(render, { onClick: () => setOpen(!open) } as Record<string, unknown>, children)
|
||||
|
||||
return <button type="button" onClick={() => setOpen(!open)}>{children}</button>
|
||||
},
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const { open } = useDropdownMenuContext()
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
DropdownMenuItem: ({ children, onClick }: { children: React.ReactNode, onClick?: React.MouseEventHandler<HTMLButtonElement> }) => {
|
||||
const { setOpen } = useDropdownMenuContext()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createLogItem = (overrides: Partial<AgentLogItemWithChildren> = {}): AgentLogItemWithChildren => ({
|
||||
message_id: 'message-1',
|
||||
label: 'Planner',
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type { AgentLogItemWithChildren } from '@/types/workflow'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { Button } from '@/app/components/base/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
|
||||
type AgentLogNavMoreProps = {
|
||||
options: AgentLogItemWithChildren[]
|
||||
@ -19,42 +20,39 @@ const AgentLogNavMore = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 2,
|
||||
crossAxis: -54,
|
||||
}}
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<Button
|
||||
className="h-6 w-6"
|
||||
variant="ghost-accent"
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className="w-[136px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.message_id}
|
||||
className="flex h-8 cursor-pointer items-center rounded-lg px-2 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onShowAgentOrToolLog(option)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
className="h-6 w-6"
|
||||
variant="ghost-accent"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<RiMoreLine className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={2}
|
||||
alignOffset={-54}
|
||||
popupClassName="w-[136px] p-1"
|
||||
>
|
||||
{
|
||||
options.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.message_id}
|
||||
className="system-md-regular"
|
||||
onClick={() => onShowAgentOrToolLog(option)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, within } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import ZoomInOut from '../zoom-in-out'
|
||||
|
||||
const {
|
||||
@ -26,29 +26,25 @@ vi.mock('reactflow', () => ({
|
||||
}))
|
||||
|
||||
const getZoomControls = () => {
|
||||
const label = Array.from(document.querySelectorAll('div')).find((element) => {
|
||||
const label = Array.from(document.querySelectorAll('button')).find((element) => {
|
||||
return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]')
|
||||
})
|
||||
const icons = Array.from(document.querySelectorAll('svg'))
|
||||
const zoomOutIcon = document.querySelector('.i-ri-zoom-out-line')
|
||||
const zoomInIcon = document.querySelector('.i-ri-zoom-in-line')
|
||||
|
||||
if (!label || icons.length < 2)
|
||||
if (!label || !zoomOutIcon || !zoomInIcon)
|
||||
throw new Error('Missing zoom controls')
|
||||
|
||||
return {
|
||||
zoomOutTrigger: icons[0]!.parentElement as HTMLElement,
|
||||
zoomOutTrigger: zoomOutIcon.parentElement as HTMLElement,
|
||||
label,
|
||||
zoomInTrigger: icons[1]!.parentElement as HTMLElement,
|
||||
zoomInTrigger: zoomInIcon.parentElement as HTMLElement,
|
||||
}
|
||||
}
|
||||
|
||||
const openZoomMenu = () => {
|
||||
fireEvent.click(getZoomControls().label)
|
||||
|
||||
const portal = document.querySelector('[data-floating-ui-portal]')
|
||||
if (!portal)
|
||||
throw new Error('Missing zoom menu portal')
|
||||
|
||||
return within(portal as HTMLElement)
|
||||
return within(screen.getByRole('menu'))
|
||||
}
|
||||
|
||||
describe('workflow preview zoom controls', () => {
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
RiZoomInLine,
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -15,18 +10,17 @@ import {
|
||||
useReactFlow,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import TipPopup from '@/app/components/workflow/operator/tip-popup'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
|
||||
enum ZoomType {
|
||||
zoomIn = 'zoomIn',
|
||||
zoomOut = 'zoomOut',
|
||||
zoomToFit = 'zoomToFit',
|
||||
zoomTo25 = 'zoomTo25',
|
||||
zoomTo50 = 'zoomTo50',
|
||||
@ -46,7 +40,7 @@ const ZoomInOut: FC = () => {
|
||||
const { zoom } = useViewport()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const ZOOM_IN_OUT_OPTIONS = [
|
||||
const zoomOptions = [
|
||||
[
|
||||
{
|
||||
key: ZoomType.zoomTo200,
|
||||
@ -78,6 +72,8 @@ const ZoomInOut: FC = () => {
|
||||
]
|
||||
|
||||
const handleZoom = (type: string) => {
|
||||
setOpen(false)
|
||||
|
||||
if (type === ZoomType.zoomToFit)
|
||||
fitView()
|
||||
|
||||
@ -97,119 +93,98 @@ const ZoomInOut: FC = () => {
|
||||
zoomTo(2)
|
||||
}
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(v => !v)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement="top-start"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg',
|
||||
'p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]',
|
||||
'hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg',
|
||||
'p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]',
|
||||
'hover:bg-state-base-hover',
|
||||
)}
|
||||
<div className="flex h-8 w-[98px] items-center justify-between rounded-lg">
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex h-8 w-[98px] items-center justify-between rounded-lg',
|
||||
)}
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<TipPopup
|
||||
title={t('operator.zoomOut', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom <= 0.25)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<RiZoomOutLine className="h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div onClick={handleTrigger} className={cn('w-[34px] system-sm-medium text-text-tertiary hover:text-text-secondary')}>
|
||||
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
|
||||
%
|
||||
</div>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<RiZoomInLine className="h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<span aria-hidden className="i-ri-zoom-out-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<div className="w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
{
|
||||
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
|
||||
<Fragment key={i}>
|
||||
{
|
||||
i !== 0 && (
|
||||
<Divider className="m-0" />
|
||||
)
|
||||
}
|
||||
<div className="p-1">
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
</TipPopup>
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<DropdownMenuTrigger className={cn('flex h-8 w-[34px] items-center justify-center rounded-lg system-sm-medium text-text-tertiary hover:bg-black/5 hover:text-text-secondary', open && 'bg-black/5 text-text-secondary')}>
|
||||
{Number.parseFloat(`${zoom * 100}`).toFixed(0)}
|
||||
%
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-start"
|
||||
sideOffset={4}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
|
||||
{zoomOptions.map((options, groupIndex) => (
|
||||
<Fragment key={options[0]!.key}>
|
||||
{groupIndex !== 0 && (
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
)}
|
||||
<div className="p-1">
|
||||
{options.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.key}
|
||||
className="flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg py-1.5 pr-2 pl-3 system-md-regular text-text-secondary hover:bg-state-base-hover"
|
||||
className="justify-between px-3 py-1.5 system-md-regular text-text-secondary"
|
||||
onClick={() => handleZoom(option.key)}
|
||||
>
|
||||
<span>{option.text}</span>
|
||||
<div className="flex items-center space-x-0.5">
|
||||
{
|
||||
option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)
|
||||
}
|
||||
{option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={['ctrl', '1']} />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)}
|
||||
{option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TipPopup
|
||||
title={t('operator.zoomIn', { ns: 'workflow' })}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
|
||||
onClick={(e) => {
|
||||
if (zoom >= 2)
|
||||
return
|
||||
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-zoom-in-line h-4 w-4 text-text-tertiary hover:text-text-secondary" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -9,9 +9,7 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- `@/app/components/base/tooltip`
|
||||
- `@/app/components/base/modal`
|
||||
- `@/app/components/base/select` (including `custom` / `pure`)
|
||||
- `@/app/components/base/dropdown`
|
||||
- `@/app/components/base/dialog`
|
||||
- `@/app/components/base/toast` (including `context`)
|
||||
- Replacement primitives:
|
||||
- `@/app/components/base/ui/tooltip`
|
||||
- `@/app/components/base/ui/dropdown-menu`
|
||||
@ -42,12 +40,6 @@ This document tracks the migration away from legacy overlay APIs.
|
||||
- Remove remaining allowlist entries.
|
||||
- Remove legacy overlay implementations when import count reaches zero.
|
||||
|
||||
## Toast migration strategy
|
||||
|
||||
- `@/app/components/base/toast` has been replaced by `@/app/components/base/ui/toast`.
|
||||
- All new toast usage must go through `@/app/components/base/ui/toast`.
|
||||
- When a file with legacy toast usage is touched, prefer migrating that call site in the same change; full-repo toast cleanup is not required in one PR.
|
||||
|
||||
## Allowlist maintenance
|
||||
|
||||
- After each migration batch, run:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user