Compare commits

...

18 Commits

Author SHA1 Message Date
zhangx1n
608046d628 Merge remote-tracking branch 'origin/feat/app-multi-env-deployments' into feat/appdeploy-webapp-channel
# Conflicts:
#	web/config/index.ts
#	web/service/base.ts
2026-07-30 09:08:34 +08:00
twwu
414f8a4e36 fix(app): remove unnecessary 'grow' class from RowActions component 2026-07-29 23:46:12 +08:00
twwu
0cb0369b7e feat(app): add multi-environment publisher flows 2026-07-29 23:40:33 +08:00
twwu
63c1a53cf9 feat(app): add deploy latest and redeploy flows 2026-07-29 21:56:20 +08:00
twwu
370a36531f feat(app): refine access point action buttons 2026-07-29 21:39:00 +08:00
twwu
0b84445195 Merge branch 'main' into feat/app-multi-env-deployments 2026-07-29 21:24:04 +08:00
twwu
be706389b3 feat(app): implement access point page 2026-07-29 21:20:00 +08:00
twwu
4968d0cf45 feat(app): add undeploy confirmation dialog 2026-07-29 15:55:10 +08:00
twwu
d8bcc2b14c feat(app): add deployment version selection flow 2026-07-29 14:54:19 +08:00
twwu
4f8f47b6d1 feat(app): add multi-environment deployment UI 2026-07-29 13:54:47 +08:00
twwu
6e75e7c5b0 Merge branch 'main' into feat/app-multi-env-deployments 2026-07-28 18:10:30 +08:00
twwu
8c91bce282 feat(workflow): update workflow tool publish action 2026-07-28 17:58:30 +08:00
twwu
24a66293fc feat: refine app publishing and dirty tracking 2026-07-28 16:17:57 +08:00
zhangx1n
cc82b275ee fix(webapp): keep dify auth off the app-deploy gateway
Routing every public-API call by the share code alone sent signing in to
the app-deploy gateway, which does not serve it: the signin page carries
the code in redirect_url, so resolveShareCode still finds it there.
Authentication stays with Dify, which issues the token the environment
later exchanges for a passport.

The split is an allow-list of the endpoints the gateway serves rather
than an exclusion of auth paths, because /login/status is ours while
/login is not. Paths are matched after normalising the leading slash --
callers pass some bare, and matching only the slashed form quietly sent
parameters and meta to Dify, where the env- code does not exist.
2026-07-27 18:32:58 +08:00
twwu
be88e6adda feat(workflow): redesign publisher action menu 2026-07-27 16:47:33 +08:00
twwu
3b832da46a feat(workflow): show deployment details in version history
Preview workflow IDs in the copy action, add mock environment badges, and fix the action menu trigger's native button semantics.
2026-07-27 13:54:37 +08:00
twwu
8812fd649c feat: Add app detail routes for Access Point and Deploy 2026-07-27 11:04:00 +08:00
zhangx1n
39915f91e6 feat(webapp): route env- share codes to appdeploy gateway 2026-07-24 02:23:22 +08:00
183 changed files with 10561 additions and 1047 deletions

View File

@@ -363,14 +363,6 @@
"count": 1
}
},
"web/app/components/app/app-publisher/sections.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -0,0 +1,3 @@
<svg width="12" height="39" viewBox="0 0 12 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9062 0.130859L0.482939 38.4953" stroke="#101828" stroke-opacity="0.04"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

View File

@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z" fill="#354052"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,6 +1,6 @@
{
"prefix": "custom-public",
"lastModified": 1784870906,
"lastModified": 1785332090,
"icons": {
"agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"

View File

@@ -1,4 +1,4 @@
export type IconifyJSON = {
export interface IconifyJSON {
prefix: string
icons: Record<string, IconifyIcon>
aliases?: Record<string, IconifyAlias>
@@ -7,7 +7,7 @@ export type IconifyJSON = {
lastModified?: number
}
export type IconifyIcon = {
export interface IconifyIcon {
body: string
left?: number
top?: number
@@ -18,11 +18,11 @@ export type IconifyIcon = {
vFlip?: boolean
}
export type IconifyAlias = {
export interface IconifyAlias extends Omit<IconifyIcon, 'body'> {
parent: string
} & Omit<IconifyIcon, 'body'>
}
export type IconifyInfo = {
export interface IconifyInfo {
prefix: string
name: string
total: number
@@ -40,11 +40,11 @@ export type IconifyInfo = {
palette?: boolean
}
export type IconifyMetaData = {
export interface IconifyMetaData {
[key: string]: unknown
}
export type IconifyChars = {
export interface IconifyChars {
[key: string]: string
}
@@ -52,3 +52,4 @@ export declare const icons: IconifyJSON
export declare const info: IconifyInfo
export declare const metadata: IconifyMetaData
export declare const chars: IconifyChars

View File

@@ -1,8 +1,9 @@
'use strict'
const chars = require('./chars.json')
const icons = require('./icons.json')
const info = require('./info.json')
const metadata = require('./metadata.json')
const chars = require('./chars.json')
module.exports = { icons, info, metadata, chars }

View File

@@ -1,6 +1,7 @@
import chars from './chars.json' with { type: 'json' }
import icons from './icons.json' with { type: 'json' }
import info from './info.json' with { type: 'json' }
import metadata from './metadata.json' with { type: 'json' }
import chars from './chars.json' with { type: 'json' }
export { icons, info, metadata, chars }
export { chars, icons, info, metadata }

View File

@@ -1,6 +1,6 @@
{
"prefix": "custom-vender",
"lastModified": 1782516559,
"lastModified": 1785332090,
"icons": {
"agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
@@ -42,6 +42,19 @@
"body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>",
"width": 17
},
"deploy-code-block": {
"body": "<g fill=\"none\"><path d=\"M2.27624 3.99996L3.80484 2.47137L2.86203 1.52856L0.390625 3.99996L2.86203 6.47137L3.80484 5.52856L2.27624 3.99996ZM6.39063 3.99996L4.86203 2.47137L5.80484 1.52856L8.27627 3.99996L5.80484 6.47137L4.86203 5.52856L6.39063 3.99996ZM9.33347 1.99996H14.0001C14.3683 1.99996 14.6668 2.29844 14.6668 2.66663V13.3333C14.6668 13.7015 14.3683 14 14.0001 14H2.0001C1.63191 14 1.33343 13.7015 1.33343 13.3333V8H2.66677V12.6667H13.3335V3.3333H9.33347V1.99996Z\" fill=\"currentColor\"/></g>"
},
"deploy-line-5": {
"body": "<g fill=\"none\"><path d=\"M10.9062 0.130859L0.482939 38.4953\" stroke=\"currentColor\" stroke-opacity=\"0.04\"/></g>",
"width": 12,
"height": 39
},
"deploy-rocket": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M2.91699 9.33317C3.88349 9.33317 4.66699 10.1166 4.66699 11.0832C4.66699 12.0497 3.88349 12.8332 2.91699 12.8332H1.75033C1.42816 12.8332 1.16699 12.572 1.16699 12.2498V11.0832C1.16699 10.1166 1.9505 9.33317 2.91699 9.33317ZM2.91699 10.4998C2.59482 10.4998 2.33366 10.761 2.33366 11.0832V11.6665H2.91699C3.23916 11.6665 3.50033 11.4053 3.50033 11.0832C3.50033 10.761 3.23916 10.4998 2.91699 10.4998Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12.2503 1.1665C12.5725 1.16651 12.8337 1.42767 12.8337 1.74984C12.8337 4.22292 11.6271 6.07192 9.91699 7.68913V10.4998C9.91699 10.7208 9.79219 10.9228 9.59456 11.0216L6.6779 12.48C6.49712 12.5704 6.28247 12.5606 6.11051 12.4543C5.93854 12.3481 5.83366 12.1603 5.83366 11.9582V10.158L3.84212 8.1665H2.04199C1.83982 8.1665 1.6521 8.06162 1.54582 7.88965C1.4396 7.71769 1.42979 7.50305 1.52018 7.32227L2.97852 4.4056C3.07733 4.20798 3.27938 4.08317 3.50033 4.08317H6.31104C7.92825 2.37309 9.77724 1.1665 12.2503 1.1665ZM7.00033 10.186V11.0142L8.75033 10.1392V8.698L7.00033 10.186ZM11.6362 2.36336C9.82895 2.54598 8.38869 3.53818 6.99463 5.05843L4.87606 7.5507L6.44889 9.12354L8.94116 7.00496C10.4616 5.61077 11.4537 4.17089 11.6362 2.36336ZM2.98592 6.99984H3.81421L5.3016 5.24984H3.86092L2.98592 6.99984Z\" fill=\"currentColor\"/></g>",
"width": 14,
"height": 14
},
"features-citations": {
"body": "<g fill=\"none\"><path d=\"M1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12ZM7 11.9702V14.958H11.0356V11.2339H8.8125C8.78418 10.8185 8.85498 10.4173 9.0249 10.0303C9.35531 9.29395 10.002 8.77474 10.9648 8.47266V7C9.67155 7.25488 8.68506 7.79297 8.00537 8.61426C7.33512 9.43555 7 10.5542 7 11.9702ZM15.0391 10.0586C15.3695 9.29395 16.0114 8.7653 16.9648 8.47266V7C15.7093 7.25488 14.7323 7.78825 14.0337 8.6001C13.3446 9.41195 13 10.5353 13 11.9702V14.958H17.0356V11.2339H14.8125C14.7747 10.8563 14.8503 10.4645 15.0391 10.0586Z\" fill=\"currentColor\"/></g>",
"width": 24,

View File

@@ -1,4 +1,4 @@
export type IconifyJSON = {
export interface IconifyJSON {
prefix: string
icons: Record<string, IconifyIcon>
aliases?: Record<string, IconifyAlias>
@@ -7,7 +7,7 @@ export type IconifyJSON = {
lastModified?: number
}
export type IconifyIcon = {
export interface IconifyIcon {
body: string
left?: number
top?: number
@@ -18,11 +18,11 @@ export type IconifyIcon = {
vFlip?: boolean
}
export type IconifyAlias = {
export interface IconifyAlias extends Omit<IconifyIcon, 'body'> {
parent: string
} & Omit<IconifyIcon, 'body'>
}
export type IconifyInfo = {
export interface IconifyInfo {
prefix: string
name: string
total: number
@@ -40,11 +40,11 @@ export type IconifyInfo = {
palette?: boolean
}
export type IconifyMetaData = {
export interface IconifyMetaData {
[key: string]: unknown
}
export type IconifyChars = {
export interface IconifyChars {
[key: string]: string
}
@@ -52,3 +52,4 @@ export declare const icons: IconifyJSON
export declare const info: IconifyInfo
export declare const metadata: IconifyMetaData
export declare const chars: IconifyChars

View File

@@ -1,8 +1,9 @@
'use strict'
const chars = require('./chars.json')
const icons = require('./icons.json')
const info = require('./info.json')
const metadata = require('./metadata.json')
const chars = require('./chars.json')
module.exports = { icons, info, metadata, chars }

View File

@@ -1,6 +1,7 @@
import chars from './chars.json' with { type: 'json' }
import icons from './icons.json' with { type: 'json' }
import info from './info.json' with { type: 'json' }
import metadata from './metadata.json' with { type: 'json' }
import chars from './chars.json' with { type: 'json' }
export { icons, info, metadata, chars }
export { chars, icons, info, metadata }

View File

@@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 331,
"total": 334,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppAccessPointDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@@ -0,0 +1,5 @@
import { AppDetailSidebarSlot } from '../sidebar-page'
export default function AppDeployDetailSidebarSlot() {
return <AppDetailSidebarSlot />
}

View File

@@ -189,6 +189,22 @@ describe('AppDetailLayout', () => {
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should allow access point pages without app deploy or app ACL permissions', async () => {
mockPathname = '/app/app-1/access-point'
mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] }))
render(
<AppDetailLayout appId="app-1">
<div>App page content</div>
</AppDetailLayout>,
)
await waitForAppContent()
expect(mockReplace).not.toHaveBeenCalled()
expect(useStore.getState().appDetail?.id).toBe('app-1')
})
it('should allow users with layout access to open workflow pages directly', async () => {
mockPathname = '/app/app-1/workflow'

View File

@@ -0,0 +1,11 @@
import AccessPoint from '@/app/components/app/access-point'
type AppAccessPointPageProps = {
params: Promise<{ appId: string }>
}
export default async function AppAccessPointPage({ params }: AppAccessPointPageProps) {
const { appId } = await params
return <AccessPoint appId={appId} />
}

View File

@@ -0,0 +1,5 @@
import AppDeploy from '@/app/components/app/deploy'
export default function AppDeployPage() {
return <AppDeploy />
}

View File

@@ -7,10 +7,12 @@ let mockAppMode = 'chat'
let mockPathname = '/app/app-1/logs'
let mockAppPermissionKeys: string[] = []
let mockIsRbacEnabled = true
let mockEnableAppDeploy = false
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
isCurrentWorkspaceEditor: true,
},
}))
@@ -18,6 +20,7 @@ const render = (ui: Parameters<typeof renderWithConsoleQuery>[0]) =>
renderWithConsoleQuery(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
enable_app_deploy: mockEnableAppDeploy,
},
})
@@ -44,6 +47,10 @@ vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname,
@@ -72,6 +79,8 @@ describe('AppDetailSection', () => {
mockPathname = '/app/app-1/logs'
mockAppPermissionKeys = [AppACLPermission.Monitor]
mockIsRbacEnabled = true
mockEnableAppDeploy = false
mockConsoleState.current.isCurrentWorkspaceEditor = true
})
// Rendering behavior for app detail navigation entries.
@@ -183,6 +192,72 @@ describe('AppDetailSection', () => {
).not.toBeInTheDocument()
})
it('should render access point navigation using its app route', () => {
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute(
'href',
'/app/app-1/access-point',
)
expect(
screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }),
).not.toBeInTheDocument()
})
it('should render deploy navigation for workflow apps when app deploy is available', () => {
// Arrange
mockAppMode = 'workflow'
mockEnableAppDeploy = true
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy',
)
})
it.each([
{
label: 'the app is not a workflow app',
mode: 'chat',
enableAppDeploy: true,
isCurrentWorkspaceEditor: true,
},
{
label: 'app deploy is disabled',
mode: 'workflow',
enableAppDeploy: false,
isCurrentWorkspaceEditor: true,
},
{
label: 'the current workspace role cannot use app deploy',
mode: 'workflow',
enableAppDeploy: true,
isCurrentWorkspaceEditor: false,
},
])(
'should hide deploy navigation when $label',
({ mode, enableAppDeploy, isCurrentWorkspaceEditor }) => {
// Arrange
mockAppMode = mode
mockEnableAppDeploy = enableAppDeploy
mockConsoleState.current.isCurrentWorkspaceEditor = isCurrentWorkspaceEditor
// Act
render(<AppDetailSection />)
// Assert
expect(
screen.queryByRole('link', { name: 'common.appMenus.deploy' }),
).not.toBeInTheDocument()
},
)
it('should render resource access navigation when app access config permission is granted', () => {
// Arrange
mockAppPermissionKeys = [AppACLPermission.AccessConfig]

View File

@@ -3,18 +3,6 @@
import type { ComponentProps } from 'react'
import type { NavIcon } from './nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiDashboard2Fill,
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiLock2Fill,
RiLock2Line,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { Fragment, useMemo } from 'react'
@@ -24,6 +12,7 @@ import Divider from '@/app/components/base/divider'
import Annotations from '@/app/components/base/icons/src/vender/Annotations'
import { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
@@ -45,6 +34,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps<typeof Annota
AnnotationNavIcon.displayName = 'Annotations'
const createClassNameNavIcon = (iconClassName: string) => {
const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => (
<span aria-hidden className={cn(iconClassName, className)} />
)
ClassNameNavIcon.displayName = 'ClassNameNavIcon'
return ClassNameNavIcon
}
const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point')
const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line')
const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill')
const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line')
const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill')
const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line')
const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill')
const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line')
const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill')
const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line')
const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill')
const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs')
const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations')
@@ -73,6 +84,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const appDetail = useStore((state) => state.appDetail)
const appInfoActions = useAppInfoActions({
@@ -85,6 +97,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const appId = appDetail.id
const isWorkflowApp =
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
const supportsAnnotations =
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, {
@@ -100,24 +113,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['appMenus.promptEng'], { ns: 'common' }),
href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
icon: terminalWindowLineNavIcon,
selectedIcon: terminalWindowFillNavIcon,
},
]
: []),
{
name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }),
href: `/app/${appId}/access-point`,
icon: accessPointNavIcon,
selectedIcon: accessPointNavIcon,
},
...(supportsAppDeploy && isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
? [
{
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),
href: `/app/${appId}/deploy`,
icon: instanceLineNavIcon,
selectedIcon: instanceFillNavIcon,
},
]
: []),
...(appACLCapabilities.canAccessLogAndAnnotation
? [
{
name: t(($) => $['appMenus.logs'], { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
icon: fileListLineNavIcon,
selectedIcon: fileListFillNavIcon,
},
]
: []),
@@ -136,8 +159,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['appMenus.overview'], { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
icon: dashboardLineNavIcon,
selectedIcon: dashboardFillNavIcon,
},
]
: []),
@@ -146,13 +169,21 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
{
name: t(($) => $['settings.resourceAccess'], { ns: 'common' }),
href: `/app/${appId}/access-config`,
icon: RiLock2Line,
selectedIcon: RiLock2Fill,
icon: lockLineNavIcon,
selectedIcon: lockFillNavIcon,
},
]
: []),
]
}, [appDetail, t, currentUserId, workspacePermissionKeys, isRbacEnabled])
}, [
appDetail,
t,
currentUserId,
workspacePermissionKeys,
isCurrentWorkspaceEditor,
isRbacEnabled,
systemFeatures.enable_app_deploy,
])
if (!appDetail) return null

View File

@@ -0,0 +1,76 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { ApiSecretKeyButton } from '../api-secret-key-button'
const mocks = vi.hoisted(() => ({
apiKeysQuery: {
data: {
data: [
{ id: 'key-1', token: 'app-a', created_at: '1', last_used_at: '1' },
{ id: 'key-2', token: 'app-b', created_at: '2', last_used_at: '2' },
],
},
isError: false,
isPending: false,
},
}))
vi.mock('@/service/use-apps', () => ({
useAppApiKeys: () => mocks.apiKeysQuery,
}))
vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({
default: ({
appId,
canManage,
isShow,
}: {
appId?: string
canManage: boolean
isShow: boolean
}) =>
isShow ? (
<div role="dialog" aria-label="API key management">
{appId}:{String(canManage)}
</div>
) : null,
}))
describe('ApiSecretKeyButton', () => {
it('shows the current API key count and opens key management', async () => {
const user = userEvent.setup()
render(<ApiSecretKeyButton appId="app-1" canManage />)
const button = screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
})
expect(button).toBeEnabled()
await user.click(button)
expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent(
'app-1:true',
)
})
it('keeps the current count visible when service access is disabled', () => {
render(<ApiSecretKeyButton appId="app-1" canManage disabled />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
it('keeps the current count visible without management permission', () => {
render(<ApiSecretKeyButton appId="app-1" canManage={false} />)
expect(
screen.getByRole('button', {
name: 'appApi.apiKeyModal.apiSecretKey 2',
}),
).toBeDisabled()
})
})

View File

@@ -0,0 +1,211 @@
import { screen } from '@testing-library/react'
import { render } from '@/test/console/render'
import { BuiltInAccessPoints } from '../built-in-access-points'
const mocks = vi.hoisted(() => ({
appInfo: {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
} as Record<string, unknown>,
workflow: {
data: null as Record<string, unknown> | null,
isPending: false,
},
webCard: vi.fn(),
apiCard: vi.fn(),
mcpCard: vi.fn(),
triggerCard: vi.fn(),
}))
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock()
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({
data: {
webapp_auth: { enabled: true },
},
}),
}
})
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: () => undefined,
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ appDetail: mocks.appInfo }),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/service/use-workflow', () => ({
useAppWorkflow: () => mocks.workflow,
}))
vi.mock('@/utils/permission', () => ({
getAppACLCapabilities: () => ({
canEdit: false,
canReleaseAndVersion: false,
}),
}))
vi.mock('../use-built-in-actions', () => ({
useBuiltInAccessPointActions: () => ({
changeApiStatus: vi.fn(),
changeSiteStatus: vi.fn(),
handleResult: vi.fn(),
refreshAppDetail: vi.fn(),
regenerateSiteCode: vi.fn(),
saveSiteConfig: vi.fn(),
}),
}))
vi.mock('../web-app-card', () => ({
WebAppAccessPointCard: (props: Record<string, unknown>) => {
mocks.webCard(props)
return <div data-testid="web-app-card" />
},
}))
vi.mock('../service-api-card', () => ({
ServiceApiAccessPointCard: (props: Record<string, unknown>) => {
mocks.apiCard(props)
return <div data-testid="service-api-card" />
},
}))
vi.mock('../mcp-card', () => ({
MCPAccessPointCard: (props: Record<string, unknown>) => {
mocks.mcpCard(props)
return <div data-testid="mcp-card" />
},
}))
vi.mock('../trigger-card', () => ({
TriggerAccessPointCard: (props: Record<string, unknown>) => {
mocks.triggerCard(props)
return <div data-testid="trigger-card" />
},
}))
describe('BuiltInAccessPoints', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appInfo = {
id: 'app-1',
mode: 'workflow',
enable_site: false,
enable_api: false,
permission_keys: [],
}
mocks.workflow = {
data: null,
isPending: false,
}
})
it('renders the unpublished state across all access point cards', () => {
render(<BuiltInAccessPoints appId="app-1" />)
expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument()
expect(screen.getByTestId('web-app-card')).toBeInTheDocument()
expect(screen.getByTestId('service-api-card')).toBeInTheDocument()
expect(screen.getByTestId('mcp-card')).toBeInTheDocument()
expect(screen.getByTestId('trigger-card')).toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
)
})
it('renders service cards as available when a published Start node exists', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'start' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(
screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'),
).not.toBeInTheDocument()
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
})
it('enables Trigger and disables the other access points in trigger mode', () => {
mocks.workflow = {
data: {
graph: {
nodes: [{ data: { type: 'start' } }, { data: { type: 'trigger-webhook' } }],
},
},
isPending: false,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.apiCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'unavailable' }),
)
expect(mocks.mcpCard).toHaveBeenCalledWith(
expect.objectContaining({ triggerModeDisabled: true }),
)
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'available' }),
)
expect(
screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'),
).toBeInTheDocument()
})
it('keeps all cards visible while the published workflow is loading', () => {
mocks.workflow = {
data: null,
isPending: true,
}
render(<BuiltInAccessPoints appId="app-1" />)
expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' }))
expect(mocks.triggerCard).toHaveBeenCalledWith(
expect.objectContaining({ availability: 'loading' }),
)
})
})

View File

@@ -0,0 +1,104 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithNuqs } from '@/test/nuqs-testing'
import AccessPoint from '..'
let appMode = 'workflow'
let enableAppDeploy = true
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'workflow.nodes.common.memories.builtIn': 'Built-in',
})
})
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({
data: {
enable_app_deploy: enableAppDeploy,
},
}),
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appDetail: {
id: 'app-1',
mode: appMode,
permission_keys: [],
},
}),
}))
vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({
BuiltInAccessPoints: ({ appId }: { appId: string }) => (
<div data-testid="built-in-access-points">{appId}</div>
),
}))
vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({
DeployedEnvironmentAccessPoints: ({ environmentId }: { environmentId: string }) => (
<div data-testid="deployed-environment-access-points">{environmentId}</div>
),
}))
describe('AccessPoint', () => {
beforeEach(() => {
appMode = 'workflow'
enableAppDeploy = true
})
it('renders the page and environment tabs without requiring deploy permission', () => {
renderWithNuqs(<AccessPoint appId="app-1" />)
expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toHaveTextContent('app-1')
expect(screen.getAllByRole('tab')).toHaveLength(9)
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
})
it('persists the selected environment in the URL', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(<AccessPoint appId="app-1" />)
await user.click(screen.getByRole('tab', { name: 'Canary' }))
expect(onUrlUpdate).toHaveBeenCalledWith(
expect.objectContaining({
queryString: '?environment=canary',
}),
)
})
it('shows a read-only deployed environment view for non-built-in tabs', () => {
renderWithNuqs(<AccessPoint appId="app-1" />, {
searchParams: '?environment=canary',
})
expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary')
expect(screen.queryByTestId('built-in-access-points')).not.toBeInTheDocument()
})
it.each([
{ mode: 'workflow', featureEnabled: false, label: 'the feature is disabled' },
{ mode: 'chat', featureEnabled: true, label: 'the app has no multi-environment support' },
])(
'hides environment tabs when $label but keeps the page visible',
({ featureEnabled, mode }) => {
appMode = mode
enableAppDeploy = featureEnabled
renderWithNuqs(<AccessPoint appId="app-1" />)
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument()
},
)
})

View File

@@ -0,0 +1,159 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useId } from 'react'
export type AccessPointStatus = 'disabled' | 'inService' | 'unavailable'
type AccessPointCardProps = {
actions?: ReactNode
children: ReactNode
description: string
icon: ReactNode | string
status: AccessPointStatus
statusLabel: string
title: string
busy?: boolean
className?: string
onEnabledChange?: (enabled: boolean) => void
showStatus?: boolean
switchDisabled?: boolean
switchLabel?: string
}
export function AccessPointCard({
actions,
busy = false,
children,
className,
description,
icon,
onEnabledChange,
showStatus = true,
status,
statusLabel,
switchDisabled = false,
switchLabel,
title,
}: AccessPointCardProps) {
const titleId = useId()
const isEnabled = status === 'inService'
const showSwitch = status !== 'unavailable' && Boolean(onEnabledChange)
return (
<article
aria-labelledby={titleId}
className={cn(
'flex min-h-68 min-w-0 flex-col gap-0.5 rounded-xl bg-background-section-burn p-1',
className,
)}
>
<header className="flex min-h-14 shrink-0 items-center gap-2.5 py-2 pr-5 pl-2">
{typeof icon === 'string' ? (
<span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary">
<span aria-hidden className={cn(icon, 'size-5')} />
</span>
) : (
icon
)}
<span className="min-w-0 flex-1">
<h2 id={titleId} className="truncate system-md-semibold text-text-primary">
{title}
</h2>
<span className="block truncate system-xs-regular text-text-tertiary">{description}</span>
</span>
{showStatus && (
<>
<span
className={cn(
'flex shrink-0 items-center gap-1 system-xs-semibold-uppercase',
status === 'inService' ? 'text-text-success' : 'text-text-tertiary',
)}
>
<StatusDot status={status === 'inService' ? 'success' : 'disabled'} />
{statusLabel}
</span>
{showSwitch && (
<Switch
checked={isEnabled}
disabled={switchDisabled}
loading={busy}
aria-label={switchLabel || title}
onCheckedChange={onEnabledChange}
/>
)}
</>
)}
</header>
<div className="flex min-h-0 flex-1 flex-col rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="min-h-0 flex-1">{children}</div>
{actions !== undefined && (
<footer className="flex shrink-0 flex-wrap items-center gap-2 border-t-[0.5px] border-divider-subtle p-4">
{actions}
</footer>
)}
</div>
</article>
)
}
type AccessPointEndpointProps = {
actions?: ReactNode
label: string
unavailableLabel: string
value: string
dimmed?: boolean
loading?: boolean
unavailable?: boolean
}
export function AccessPointEndpoint({
actions,
dimmed = false,
label,
loading = false,
unavailable = false,
unavailableLabel,
value,
}: AccessPointEndpointProps) {
return (
<div className="flex flex-col gap-1 px-4 py-3">
<div className="flex h-6 items-center system-xs-medium text-text-secondary">{label}</div>
<div className="flex h-9 min-w-0 items-center gap-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-components-input-bg-normal py-1 pr-1 pl-2">
{unavailable && !loading && (
<span className="shrink-0 rounded-[5px] border border-divider-deep px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{unavailableLabel}
</span>
)}
<div className="flex min-w-0 flex-1 items-center px-1">
{loading ? (
<span className="h-2 w-[42%] animate-pulse rounded-full bg-text-quaternary opacity-20 motion-reduce:animate-none" />
) : (
<span
className={cn(
'truncate system-sm-regular text-text-secondary',
(dimmed || unavailable) && 'text-text-quaternary',
)}
translate="no"
>
{value}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5">{actions}</div>
</div>
</div>
)
}
export function AccessPointEmptyContent({ children }: { children: ReactNode }) {
return (
<div className="flex h-full min-h-36 items-center justify-center px-6 py-8 text-center system-xs-regular whitespace-pre-line text-text-tertiary">
{children}
</div>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import ShareQRCode from '@/app/components/base/qrcode'
import ActionButton from '../../base/action-button'
import { AccessPointEndpoint } from './access-point-card'
type AccessPointUrlProps = {
enabled: boolean
label: string
unavailableLabel: string
value: string
copyDisabled?: boolean
loading?: boolean
unavailable?: boolean
showOpen?: boolean
showQrCode?: boolean
showRegenerate?: boolean
onOpen?: () => void
onRegenerate?: () => void
openLabel?: string
regenerateLabel?: string
regenerateDisabled?: boolean
regenerating?: boolean
}
export function AccessPointUrl({
enabled,
label,
loading = false,
copyDisabled = false,
onOpen,
onRegenerate,
openLabel,
regenerateDisabled = false,
regenerateLabel,
regenerating = false,
showOpen = false,
showQrCode = false,
showRegenerate = false,
unavailable = false,
unavailableLabel,
value,
}: AccessPointUrlProps) {
const detailsAvailable = !loading && !unavailable
const disabledActions = (
<div className="flex items-center gap-0.5">
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-file-copy-line size-4 text-text-disabled" />
</div>
{showQrCode && (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-qr-code-line size-4 text-text-disabled" />
</div>
)}
{showRegenerate && (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-loop-left-line size-4 text-text-disabled" />
</div>
)}
{showOpen && (
<>
<span className="mx-1 h-3.5 w-px bg-divider-subtle" />
<div className="flex h-6 cursor-not-allowed items-center gap-1 rounded-md border-[0.5px] border-components-button-secondary-border-disabled px-1.5 system-sm-medium text-components-button-secondary-text-disabled backdrop-blur-xs">
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</div>
</>
)}
</div>
)
const availableActions = (
<div className="flex items-center gap-0.5">
{copyDisabled ? (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-file-copy-line size-4 text-text-disabled" />
</div>
) : (
<CopyFeedback content={value} className="size-6!" />
)}
{showQrCode &&
(copyDisabled ? (
<div className="flex cursor-not-allowed items-center justify-center p-0.5">
<span aria-hidden className="i-ri-qr-code-line size-4 text-text-disabled" />
</div>
) : (
<ShareQRCode content={value} />
))}
{showRegenerate && (
<ActionButton
className="size-6 px-0"
aria-label={regenerateLabel}
disabled={regenerateDisabled || regenerating}
onClick={onRegenerate}
>
<span
aria-hidden
className={`i-ri-loop-left-line size-4 ${regenerating ? 'animate-spin' : ''}`}
/>
</ActionButton>
)}
{showOpen && (
<>
<span className="mx-1 h-3.5 w-px bg-divider-regular" />
<Button
variant="secondary"
size="small"
className="h-6 gap-1 px-1.5"
disabled={!enabled}
onClick={onOpen}
>
<span aria-hidden className="i-ri-external-link-line size-3.5" />
{openLabel}
</Button>
</>
)}
</div>
)
return (
<AccessPointEndpoint
label={label}
value={value}
unavailableLabel={unavailableLabel}
unavailable={!loading && !unavailable && !enabled}
dimmed={!enabled}
loading={loading || unavailable}
actions={detailsAvailable ? availableActions : disabledActions}
/>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import { useAppApiKeys } from '@/service/use-apps'
type ApiSecretKeyButtonProps = {
appId: string
canManage: boolean
disabled?: boolean
}
export function ApiSecretKeyButton({
appId,
canManage,
disabled = false,
}: ApiSecretKeyButtonProps) {
const { t } = useTranslation()
const [modalOpen, setModalOpen] = useState(false)
const apiKeysQuery = useAppApiKeys(appId)
const apiKeyCount = apiKeysQuery.data?.data.length ?? 0
const buttonDisabled = disabled || !canManage || apiKeysQuery.isPending || apiKeysQuery.isError
return (
<>
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={buttonDisabled}
onClick={() => setModalOpen(true)}
>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t(($) => $['apiKeyModal.apiSecretKey'], { ns: 'appApi' })}
<span
className={cn(
'flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase tabular-nums',
buttonDisabled ? 'text-text-disabled' : 'text-text-tertiary',
)}
>
{apiKeyCount}
</span>
</Button>
<SecretKeyModal
appId={appId}
canManage={canManage}
isShow={modalOpen}
onClose={() => setModalOpen(false)}
/>
</>
)
}

View File

@@ -0,0 +1,154 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { userProfileIdAtom } from '@/context/account-state'
import { useDocLink } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
import { useAppWorkflow } from '@/service/use-workflow'
import { getAppACLCapabilities } from '@/utils/permission'
import { MCPAccessPointCard } from './mcp-card'
import { ServiceApiAccessPointCard } from './service-api-card'
import { TriggerAccessPointCard } from './trigger-card'
import { useBuiltInAccessPointActions } from './use-built-in-actions'
import { getPublishedWorkflowState, isAdvancedApp } from './utils'
import { WebAppAccessPointCard } from './web-app-card'
type BuiltInAccessPointsProps = {
appId: string
}
export function BuiltInAccessPoints({ appId }: BuiltInAccessPointsProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const appInfo = useAppStore((state) => state.appDetail)
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo))
const { data: workflow, isPending: workflowLoading } = useAppWorkflow(
shouldFetchWorkflow ? appId : '',
)
const capabilities = useMemo(
() =>
getAppACLCapabilities(appInfo?.permission_keys, {
currentUserId,
resourceMaintainer: appInfo?.maintainer,
workspacePermissionKeys,
}),
[appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys],
)
const actions = useBuiltInAccessPointActions(appId, capabilities.canEdit)
if (!appInfo) return <Loading />
const workflowState = getPublishedWorkflowState(appInfo, workflow)
const builtInLoading = workflowState.isWorkflowApp && workflowLoading
const appCardsUnavailable =
workflowState.isWorkflowApp &&
(workflowState.isUnpublished || !workflowState.hasStartNode || workflowState.hasTriggerNode)
const appCardAvailability = builtInLoading
? 'loading'
: appCardsUnavailable
? 'unavailable'
: 'available'
const triggerAvailability = builtInLoading
? 'loading'
: workflowState.isUnpublished || !workflowState.hasTriggerNode
? 'unavailable'
: 'available'
return (
<div className="flex h-full flex-col gap-2">
{workflowState.isUnpublished && !workflowLoading && (
<div className="flex flex-col items-start gap-2 rounded-xl bg-background-section-burn p-3">
<div className="flex flex-col gap-0.5">
<span className="block system-md-semibold text-text-secondary">
{t(($) => $['studio.accessPoint.noPublishedTitle'], {
ns: 'deployments',
})}
</span>
<span className="block system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.noPublishedDescription'], {
ns: 'deployments',
})}
</span>
</div>
<Button
variant="primary"
size="medium"
disabled={!capabilities.canReleaseAndVersion}
render={<Link href={`/app/${appId}/workflow`} />}
className="flex items-center gap-1"
>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
</div>
)}
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<WebAppAccessPointCard
appInfo={appInfo}
workflow={workflow}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
canManageAccess={capabilities.canReleaseAndVersion}
showAccessControl={systemFeatures.webapp_auth.enabled}
onChangeStatus={actions.changeSiteStatus}
onRefreshApp={actions.refreshAppDetail}
onRegenerate={actions.regenerateSiteCode}
onSaveSiteConfig={actions.saveSiteConfig}
/>
<ServiceApiAccessPointCard
appInfo={appInfo}
availability={appCardAvailability}
canEdit={capabilities.canEdit}
onChangeStatus={actions.changeApiStatus}
/>
<MCPAccessPointCard
appInfo={appInfo}
canEdit={capabilities.canEdit}
workflow={workflow}
workflowLoading={workflowLoading}
triggerModeDisabled={workflowState.hasTriggerNode}
/>
{workflowState.isWorkflowApp && (
<TriggerAccessPointCard
appInfo={appInfo}
availability={triggerAvailability}
unavailableReason={workflowState.isUnpublished ? 'unpublished' : 'serviceMode'}
canEdit={capabilities.canEdit}
onToggleResult={actions.handleResult}
/>
)}
</div>
{workflowState.hasTriggerNode && (
<div className="mt-2 flex min-h-10 items-center gap-2 rounded-xl bg-background-section-burn px-3 py-2 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-information-line size-4 shrink-0" />
<span>
{t(($) => $['studio.accessPoint.triggerExclusiveNotice'], {
ns: 'deployments',
})}{' '}
<Link
href={docLink('/use-dify/nodes/trigger/overview')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
>
{t(($) => $['operation.learnMore'], { ns: 'common' })}
</Link>
</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/mock-data'
import { useTranslation } from 'react-i18next'
import { MOCK_ENVIRONMENT_DEPLOYMENTS } from '@/app/components/app/deploy/mock-data'
import { AccessPointCard, AccessPointEmptyContent, AccessPointEndpoint } from './access-point-card'
const ACCESS_POINT_CONFIG: Record<
AccessPoint,
{
description: 'api' | 'mcp' | 'trigger' | 'webApp'
icon: string
title: 'api' | 'mcp' | 'trigger' | 'webApp'
}
> = {
webApp: {
description: 'webApp',
icon: 'i-ri-robot-2-line',
title: 'webApp',
},
serviceApi: {
description: 'api',
icon: 'i-custom-vender-knowledge-api-aggregate',
title: 'api',
},
mcp: {
description: 'mcp',
icon: 'i-custom-vender-integrations-mcp',
title: 'mcp',
},
trigger: {
description: 'trigger',
icon: 'i-custom-vender-integrations-trigger',
title: 'trigger',
},
}
const ACCESS_POINT_ORDER: AccessPoint[] = ['webApp', 'serviceApi', 'mcp', 'trigger']
type DeployedEnvironmentAccessPointsProps = {
environmentId: string
}
export function DeployedEnvironmentAccessPoints({
environmentId,
}: DeployedEnvironmentAccessPointsProps) {
const { t } = useTranslation()
const deployment = MOCK_ENVIRONMENT_DEPLOYMENTS.find(
(candidate) => candidate.id === environmentId,
)
const title = (accessPoint: AccessPoint) => {
const key = ACCESS_POINT_CONFIG[accessPoint].title
if (key === 'webApp') return t(($) => $['agentDetail.access.webApp.title'], { ns: 'agentV2' })
if (key === 'api') return t(($) => $['agentDetail.access.serviceApi.title'], { ns: 'agentV2' })
if (key === 'mcp') return t(($) => $['mcp.server.title'], { ns: 'tools' })
return t(($) => $['settings.trigger'], { ns: 'common' })
}
const description = (accessPoint: AccessPoint) => {
const key = ACCESS_POINT_CONFIG[accessPoint].description
if (key === 'webApp')
return t(($) => $['studio.accessPoint.webAppDescription'], {
ns: 'deployments',
})
if (key === 'api')
return t(($) => $['studio.accessPoint.apiDescription'], {
ns: 'deployments',
})
if (key === 'mcp')
return t(($) => $['studio.accessPoint.mcpDescription'], {
ns: 'deployments',
})
return t(($) => $['studio.accessPoint.triggerDescription'], {
ns: 'deployments',
})
}
const endpointLabel = (accessPoint: Exclude<AccessPoint, 'trigger'>) => {
if (accessPoint === 'webApp')
return t(($) => $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })
if (accessPoint === 'serviceApi')
return t(($) => $['overview.apiInfo.accessibleAddress'], { ns: 'appOverview' })
return t(($) => $['mcp.server.url'], { ns: 'tools' })
}
return (
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
{ACCESS_POINT_ORDER.map((accessPoint) => {
const inService =
deployment?.status === 'running' && deployment.accessPoints.includes(accessPoint)
const statusLabel = inService
? t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' })
: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' })
return (
<AccessPointCard
key={accessPoint}
title={title(accessPoint)}
description={description(accessPoint)}
icon={ACCESS_POINT_CONFIG[accessPoint].icon}
status={inService ? 'inService' : 'unavailable'}
statusLabel={statusLabel}
>
{accessPoint === 'trigger' ? (
<AccessPointEmptyContent>
{t(($) => $['studio.accessPoint.triggerServiceModeUnavailable'], {
ns: 'deployments',
})}
</AccessPointEmptyContent>
) : (
<AccessPointEndpoint
label={endpointLabel(accessPoint)}
value=""
unavailableLabel={statusLabel}
loading
/>
)}
</AccessPointCard>
)
})}
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { Tabs, TabsList, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { MOCK_ENVIRONMENT_DEPLOYMENTS } from '@/app/components/app/deploy/mock-data'
import { useStore as useAppStore } from '@/app/components/app/store'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AppModeEnum } from '@/types/app'
import { BuiltInAccessPoints } from './built-in-access-points'
import { DeployedEnvironmentAccessPoints } from './deployed-environment-access-points'
// todo: mock data, remove after backend is ready
const ENVIRONMENT_IDS = [
'built-in',
'staging',
'pre-release',
'canary',
'prod',
'eu-prod',
'qa',
'sandbox',
'preview',
] as const
type EnvironmentId = (typeof ENVIRONMENT_IDS)[number]
const environmentQueryState = parseAsStringLiteral(ENVIRONMENT_IDS)
.withDefault('built-in')
.withOptions({ clearOnDefault: true })
const environmentNames = new Map<EnvironmentId, string>([
...MOCK_ENVIRONMENT_DEPLOYMENTS.map(
(environment) => [environment.id as EnvironmentId, environment.name] as const,
),
])
type AccessPointProps = {
appId: string
}
export default function AccessPoint({ appId }: AccessPointProps) {
const { t } = useTranslation()
const appMode = useAppStore((state) => state.appDetail?.mode)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [environment, setEnvironment] = useQueryState('environment', environmentQueryState)
const showEnvironmentTabs = appMode === AppModeEnum.WORKFLOW && systemFeatures.enable_app_deploy
return (
<main className="flex h-full min-h-0 flex-col bg-components-panel-bg">
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
<div className="flex flex-col gap-0.5">
<div className="flex h-6 items-center">
<h1 className="title-xl-semi-bold text-text-primary">
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</h1>
</div>
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.description'], { ns: 'deployments' })}
</p>
</div>
{showEnvironmentTabs && (
<Tabs
value={environment}
onValueChange={(value) => void setEnvironment(value as EnvironmentId)}
>
<div className="overflow-x-auto">
<TabsList
aria-label={t(($) => $['studio.environments'], { ns: 'deployments' })}
className="min-w-max gap-1"
>
{ENVIRONMENT_IDS.map((environmentId) => (
<TabsTab
key={environmentId}
value={environmentId}
className="h-8 rounded-lg border-b-0 px-2.5 py-0 system-sm-medium data-active:border-transparent data-active:bg-state-base-active data-active:system-sm-semibold data-active:text-text-secondary"
>
{environmentId === 'built-in'
? t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' })
: environmentNames.get(environmentId)}
</TabsTab>
))}
</TabsList>
</div>
</Tabs>
)}
</header>
<div
className="min-h-0 flex-1 overflow-y-auto px-6 py-2"
data-environment={showEnvironmentTabs ? environment : 'built-in'}
>
{environment === 'built-in' || !showEnvironmentTabs ? (
<BuiltInAccessPoints appId={appId} />
) : (
<DeployedEnvironmentAccessPoints environmentId={environment} />
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,233 @@
'use client'
import type { AccessPointAppInfo, PublishedWorkflow } from './utils'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { fetchAppDetail } from '@/service/apps'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from './access-point-card'
import { AccessPointUrl } from './access-point-url'
import { isAdvancedApp } from './utils'
type BasicAppConfig = {
updated_at?: string
user_input_form?: Array<Record<string, unknown>>
}
type MCPAccessPointCardProps = {
appInfo: AccessPointAppInfo
canEdit: boolean
triggerModeDisabled: boolean
workflow: PublishedWorkflow
workflowLoading: boolean
}
export function MCPAccessPointCard({
appInfo,
canEdit,
triggerModeDisabled,
workflow,
workflowLoading,
}: MCPAccessPointCardProps) {
const { t } = useTranslation()
const advancedApp = isAdvancedApp(appInfo)
const basicApp = !advancedApp
const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const [showServerModal, setShowServerModal] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const { data: basicConfig = {}, isPending: basicConfigLoading } = useQuery<BasicAppConfig>({
queryKey: ['access-point', 'basic-app-config', appInfo.id],
queryFn: async () => {
const appDetail = await fetchAppDetail({ url: '/apps', id: appInfo.id })
return (appDetail.model_config as BasicAppConfig) || {}
},
enabled: basicApp,
})
const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail(
appInfo.id,
Boolean(appInfo.id),
)
const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer()
const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode()
const invalidateServerDetail = useInvalidateMCPServerDetail()
const serverPublished = Boolean(detail?.id)
const serverActivated = detail?.status === 'active'
const activated = pendingStatus ?? serverActivated
const serverUrl = serverPublished
? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp`
: '***********'
const workflowNodes = useMemo(() => workflow?.graph?.nodes ?? [], [workflow?.graph?.nodes])
const missingStartNode =
workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start)
const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig.updated_at
const loading = serverDetailLoading || (advancedApp ? workflowLoading : basicConfigLoading)
const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled)
const basicAppInputs = useMemo(() => {
if (!basicApp || !basicConfig.user_input_form) return []
return basicConfig.user_input_form.map((item) => {
const type = Object.keys(item)[0]
return {
...(item[type!] as object),
type: type || 'text-input',
}
})
}, [basicApp, basicConfig.user_input_form])
const latestParams = useMemo(() => {
if (!advancedApp) return basicAppInputs
const startNode = workflowNodes.find((node) => node.data.type === BlockEnum.Start)
return (
(
startNode?.data as {
variables?: Array<{ variable: string; label: string }>
}
)?.variables ?? []
)
}, [advancedApp, basicAppInputs, workflowNodes])
const handleStatusChange = async (enabled: boolean) => {
if (!canEdit || loading || unavailable) return
if (enabled && !serverPublished) {
setShowServerModal(true)
return
}
setPendingStatus(enabled)
try {
await updateServer({
appID: appInfo.id,
id: detail?.id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: enabled ? 'active' : 'inactive',
})
invalidateServerDetail(appInfo.id)
} finally {
setPendingStatus(null)
}
}
const handleRegenerate = async () => {
if (!canEdit || !detail?.id) return
await refreshServerCode(detail.id)
invalidateServerDetail(appInfo.id)
setShowRegenerate(false)
}
const status = loading || unavailable ? 'unavailable' : activated ? 'inService' : 'disabled'
const statusLabel =
loading || unavailable
? t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' })
: activated
? t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' })
: t(($) => $['overview.status.disable'], { ns: 'appOverview' })
return (
<>
<AccessPointCard
title={t(($) => $['mcp.server.title'], { ns: 'tools' })}
description={t(($) => $['studio.accessPoint.mcpDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-mcp"
status={status}
statusLabel={statusLabel}
busy={statusUpdating}
switchDisabled={!canEdit}
switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })}
onEnabledChange={loading || unavailable ? undefined : handleStatusChange}
actions={
<Button
variant="secondary"
disabled={loading || unavailable || !canEdit}
onClick={() => setShowServerModal(true)}
className="flex items-center gap-1 px-3"
>
<span aria-hidden className="i-ri-draft-line size-4" />
{serverPublished
? t(($) => $['mcp.server.edit'], { ns: 'tools' })
: t(($) => $['mcp.server.addDescription'], { ns: 'tools' })}
</Button>
}
>
<AccessPointUrl
label={t(($) => $['mcp.server.url'], { ns: 'tools' })}
value={serverUrl}
enabled={activated}
copyDisabled={!serverPublished}
loading={loading}
unavailable={unavailable}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showRegenerate
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canEdit || !serverPublished}
regenerating={regenerating}
onRegenerate={() => setShowRegenerate(true)}
/>
</AccessPointCard>
{showServerModal && (
<MCPServerModal
show
appID={appInfo.id}
data={serverPublished ? detail : undefined}
latestParams={latestParams}
onHide={() => {
setShowServerModal(false)
setPendingStatus(null)
invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}
/>
)}
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['mcp.server.reGen'], { ns: 'tools' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import type { AccessPointAppInfo } from './utils'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { AccessPointCard } from './access-point-card'
import { AccessPointUrl } from './access-point-url'
import { ApiSecretKeyButton } from './api-secret-key-button'
import { getBuiltInAccessUrls } from './utils'
type ServiceApiAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: 'available' | 'loading' | 'unavailable'
canEdit: boolean
onChangeStatus: (enabled: boolean) => Promise<void>
}
export function ServiceApiAccessPointCard({
appInfo,
availability,
canEdit,
onChangeStatus,
}: ServiceApiAccessPointCardProps) {
const { t } = useTranslation()
const { api: apiUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_api
const status = availability !== 'available' ? 'unavailable' : running ? 'inService' : 'disabled'
const statusLabel =
availability !== 'available'
? t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' })
: running
? t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' })
: t(($) => $['overview.status.disable'], { ns: 'appOverview' })
return (
<AccessPointCard
title={t(($) => $['agentDetail.access.serviceApi.title'], { ns: 'agentV2' })}
description={t(($) => $['studio.accessPoint.apiDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-knowledge-api-aggregate"
status={status}
statusLabel={statusLabel}
switchDisabled={!canEdit}
switchLabel={t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
actions={
<>
<ApiSecretKeyButton appId={appInfo.id} canManage={canEdit} disabled={!running} />
<Button
variant="secondary"
disabled={availability !== 'available'}
render={<Link href={`/app/${appInfo.id}/develop`} />}
className="flex items-center gap-1"
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' })}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Button>
</>
}
>
<AccessPointUrl
label={t(($) => $['overview.apiInfo.accessibleAddress'], {
ns: 'appOverview',
})}
value={apiUrl}
enabled={running}
loading={availability === 'loading'}
unavailable={availability === 'unavailable'}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
/>
</AccessPointCard>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import type { AccessPointAppInfo } from './utils'
import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
import type { AppTrigger } from '@/service/use-tools'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { BlockEnum } from '@/app/components/workflow/types'
import {
useAppTriggers,
useInvalidateAppTriggers,
useUpdateTriggerStatus,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { AccessPointCard, AccessPointEmptyContent } from './access-point-card'
function TriggerIcon({
trigger,
triggerPlugins,
}: {
trigger: AppTrigger
triggerPlugins: TriggerWithProvider[]
}) {
const blockType =
trigger.trigger_type === 'trigger-schedule'
? BlockEnum.TriggerSchedule
: trigger.trigger_type === 'trigger-plugin'
? BlockEnum.TriggerPlugin
: BlockEnum.TriggerWebhook
const pluginTrigger =
trigger.trigger_type === 'trigger-plugin' && trigger.provider_name
? triggerPlugins.find(
(candidate) =>
canFindTool(candidate.id, trigger.provider_name!) ||
candidate.id.includes(trigger.provider_name!) ||
candidate.name === trigger.provider_name,
)
: undefined
const toolIcon = typeof pluginTrigger?.icon === 'string' ? pluginTrigger.icon : undefined
return (
<span>
<BlockIcon type={blockType} size="md" toolIcon={toolIcon} />
</span>
)
}
type TriggerAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: 'available' | 'loading' | 'unavailable'
canEdit: boolean
unavailableReason: 'serviceMode' | 'unpublished'
onToggleResult: (error: Error | null) => void
}
export function TriggerAccessPointCard({
appInfo,
availability,
canEdit,
onToggleResult,
unavailableReason,
}: TriggerAccessPointCardProps) {
const { t } = useTranslation()
const { data: response, isLoading } = useAppTriggers(appInfo.id)
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus()
const invalidateTriggers = useInvalidateAppTriggers()
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const triggers = useMemo(() => response?.data ?? [], [response?.data])
const loading = availability === 'loading' || isLoading
const active = availability === 'available' && !loading
const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length
useEffect(() => {
if (!triggers.length) return
setTriggerStatuses(
triggers.reduce(
(statuses, trigger) => {
statuses[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
return statuses
},
{} as Record<string, 'disabled' | 'enabled'>,
),
)
}, [setTriggerStatuses, triggers])
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
if (!canEdit) return
const status = enabled ? 'enabled' : 'disabled'
setTriggerStatus(trigger.node_id, status)
try {
await updateTriggerStatus({
appId: appInfo.id,
triggerId: trigger.id,
enableTrigger: enabled,
})
invalidateTriggers(appInfo.id)
onToggleResult(null)
} catch (error) {
setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled')
onToggleResult(error as Error)
}
}
return (
<AccessPointCard
title={t(($) => $['settings.trigger'], { ns: 'common' })}
description={t(($) => $['studio.accessPoint.triggerDescription'], {
ns: 'deployments',
})}
icon="i-custom-vender-integrations-trigger"
status={active ? 'inService' : 'unavailable'}
statusLabel={
active
? t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' })
: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' })
}
showStatus={!active}
busy={statusUpdating}
>
{loading && (
<div className="flex h-full min-h-40 flex-col gap-4 px-4 py-5">
<span className="h-2 w-24 animate-pulse rounded-full bg-text-quaternary opacity-20 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
<span className="h-10 w-full animate-pulse rounded-lg bg-text-quaternary opacity-10 motion-reduce:animate-none" />
</div>
)}
{!loading && (!active || triggers.length === 0) && (
<AccessPointEmptyContent>
{!active && unavailableReason === 'serviceMode' ? (
t(($) => $['studio.accessPoint.triggerServiceModeUnavailable'], {
ns: 'deployments',
})
) : (
<span>
{t(($) => $['studio.accessPoint.noTriggerNodes'], {
ns: 'deployments',
})}
<br />
{t(($) => $['studio.accessPoint.addTriggerNodeDescription'], {
ns: 'deployments',
})}
</span>
)}
</AccessPointEmptyContent>
)}
{active && triggers.length > 0 && (
<div className="flex flex-col px-4 py-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-secondary">
{t(($) => $['studio.accessPoint.triggerEnabledCount'], {
ns: 'deployments',
enabled: enabledCount,
total: triggers.length,
})}
</div>
<div className="mt-1 flex flex-col gap-1">
{triggers.map((trigger) => {
const enabled = trigger.status === 'enabled'
const disconnected = trigger.status === 'unauthorized'
const statusLabel = enabled
? t(($) => $['agentDetail.access.status.inService'], {
ns: 'agentV2',
})
: disconnected
? t(($) => $['studio.accessPoint.triggerDisconnected'], {
ns: 'deployments',
})
: t(($) => $['studio.accessPoint.triggerMuted'], {
ns: 'deployments',
})
return (
<div
key={trigger.id}
className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
>
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
{trigger.title}
</span>
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{trigger.provider_name}
</span>
<span
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
enabled
? 'text-text-success'
: disconnected
? 'text-text-destructive'
: 'text-text-tertiary'
}`}
>
<StatusDot
size="small"
status={enabled ? 'success' : disconnected ? 'error' : 'disabled'}
/>
{statusLabel}
</span>
<Switch
checked={trigger.status !== 'disabled'}
disabled={!canEdit || statusUpdating}
aria-label={trigger.title}
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
/>
</div>
)
})}
</div>
</div>
)}
</AccessPointCard>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetNeedRefreshAppList } from '@/app/components/apps/storage'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { asyncRunSafe } from '@/utils'
export function useBuiltInAccessPointActions(appId: string, canEdit: boolean) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const setAppDetail = useAppStore((state) => state.setAppDetail)
const setNeedRefresh = useSetNeedRefreshAppList()
const refreshAppDetail = useCallback(async () => {
try {
const appDetail = await fetchAppDetail({ url: '/apps', id: appId })
queryClient.setQueryData([...appDetailQueryKeyPrefix, appId], appDetail)
setAppDetail({ ...appDetail })
} catch (error) {
console.error('Failed to refresh app detail:', error)
}
}, [appId, queryClient, setAppDetail])
const handleResult = useCallback(
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = error ? 'error' : 'success'
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
if (!error) {
void refreshAppDetail()
const socket = webSocketClient.getSocket(appId)
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp },
timestamp,
})
}
}
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
type,
})
},
[appId, refreshAppDetail, t],
)
useEffect(() => {
if (!appId) return
return collaborationManager.onAppStateUpdate(refreshAppDetail)
}, [appId, refreshAppDetail])
const changeSiteStatus = useCallback(
async (enabled: boolean) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/site-enable`,
body: { enable_site: enabled },
}) as Promise<App>,
)
handleResult(error)
},
[appId, canEdit, handleResult],
)
const changeApiStatus = useCallback(
async (enabled: boolean) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
body: { enable_api: enabled },
}) as Promise<App>,
)
handleResult(error)
},
[appId, canEdit, handleResult],
)
const saveSiteConfig = useCallback(
async (params: ConfigParams) => {
if (!canEdit) return
const [error] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
body: params,
}) as Promise<App>,
)
if (!error) setNeedRefresh('1')
handleResult(error)
},
[appId, canEdit, handleResult, setNeedRefresh],
)
const regenerateSiteCode = useCallback(async () => {
if (!canEdit) return
const [error] = await asyncRunSafe<UpdateAppSiteCodeResponse>(
updateAppSiteAccessToken({
url: `/apps/${appId}/site/access-token-reset`,
}) as Promise<UpdateAppSiteCodeResponse>,
)
handleResult(error, error ? 'generatedUnsuccessfully' : 'generatedSuccessfully')
}, [appId, canEdit, handleResult])
return {
changeApiStatus,
changeSiteStatus,
handleResult,
refreshAppDetail,
regenerateSiteCode,
saveSiteConfig,
}
}

View File

@@ -0,0 +1,53 @@
import type { InputVar } from '@/app/components/workflow/types'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
export type AccessPointAppInfo = AppDetailResponse & Partial<AppSSO>
export type PublishedWorkflow = FetchWorkflowDraftResponse | null | undefined
export function getPublishedWorkflowState(
appInfo: AccessPointAppInfo,
workflow: PublishedWorkflow,
) {
const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const nodes = workflow?.graph?.nodes ?? []
const hasStartNode = nodes.some((node) => node.data.type === BlockEnum.Start)
const hasTriggerNode = nodes.some((node) => isTriggerNode(node.data.type))
return {
hasStartNode,
hasTriggerNode,
isUnpublished: isWorkflowApp && !workflow?.graph,
isWorkflowApp,
}
}
export function getBuiltInAccessUrls(appInfo: AccessPointAppInfo) {
const appMode =
appInfo.mode === AppModeEnum.COMPLETION || appInfo.mode === AppModeEnum.WORKFLOW
? appInfo.mode
: AppModeEnum.CHAT
return {
api: appInfo.api_base_url ?? '',
webApp: `${appInfo.site?.app_base_url ?? ''}${basePath}/${appMode}/${
appInfo.site?.access_token ?? ''
}`,
}
}
export function getHiddenStartInputs(workflow: PublishedWorkflow) {
const startNode = workflow?.graph?.nodes.find((node) => node.data.type === BlockEnum.Start)
return ((startNode?.data as { variables?: InputVar[] } | undefined)?.variables ?? []).filter(
(variable) => variable.hide === true,
)
}
export function isAdvancedApp(appInfo: AccessPointAppInfo) {
return appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT
}

View File

@@ -0,0 +1,280 @@
'use client'
import type { SelectorParam } from 'i18next'
import type { AccessPointAppInfo, PublishedWorkflow } from './utils'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessControl from '@/app/components/app/app-access-control'
import CustomizeModal from '@/app/components/app/overview/customize'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import SettingsModal from '@/app/components/app/overview/settings'
import AppIcon from '@/app/components/base/app-icon'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from './access-point-card'
import { AccessPointUrl } from './access-point-url'
import { getBuiltInAccessUrls, getHiddenStartInputs } from './utils'
type Availability = 'available' | 'loading' | 'unavailable'
const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line',
[AccessMode.PUBLIC]: 'i-ri-global-line',
[AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line',
}
const ACCESS_MODE_LABEL_MAP: Record<AccessMode, SelectorParam<'app'>> = {
[AccessMode.ORGANIZATION]: ($) => $['accessControlDialog.accessItems.organization'],
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: ($) => $['accessControlDialog.accessItems.specific'],
[AccessMode.PUBLIC]: ($) => $['accessControlDialog.accessItems.anyone'],
[AccessMode.EXTERNAL_MEMBERS]: ($) => $['accessControlDialog.accessItems.external'],
}
type WebAppAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: Availability
canEdit: boolean
canManageAccess: boolean
showAccessControl: boolean
workflow: PublishedWorkflow
onChangeStatus: (enabled: boolean) => Promise<void>
onRefreshApp: () => Promise<void>
onRegenerate: () => Promise<void>
onSaveSiteConfig: (params: ConfigParams) => Promise<void>
}
export function WebAppAccessPointCard({
appInfo,
availability,
canEdit,
canManageAccess,
onChangeStatus,
onRefreshApp,
onRegenerate,
onSaveSiteConfig,
showAccessControl,
workflow,
}: WebAppAccessPointCardProps) {
const { t } = useTranslation()
const [showSettings, setShowSettings] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_site
const accessIcon = ACCESS_MODE_ICON_MAP[appInfo.access_mode]
const accessLabel = ACCESS_MODE_LABEL_MAP[appInfo.access_mode]
const hiddenInputs = useMemo(() => getHiddenStartInputs(workflow), [workflow])
const { data: accessSubjects } = useAppWhiteListSubjects(
appInfo.id,
showAccessControl &&
canManageAccess &&
appInfo.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const accessConfigured =
!accessSubjects ||
appInfo.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS ||
Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length)
const handleRegenerate = async () => {
setRegenerating(true)
await onRegenerate()
setRegenerating(false)
setShowRegenerate(false)
}
const status = availability !== 'available' ? 'unavailable' : running ? 'inService' : 'disabled'
const statusLabel =
availability !== 'available'
? t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' })
: running
? t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' })
: t(($) => $['overview.status.disable'], { ns: 'appOverview' })
return (
<>
<AccessPointCard
title={t(($) => $['agentDetail.access.webApp.title'], { ns: 'agentV2' })}
description={t(($) => $['studio.accessPoint.webAppDescription'], {
ns: 'deployments',
})}
icon={
<AppIcon
size="large"
iconType={appInfo.icon_type}
icon={appInfo.icon}
background={appInfo.icon_background}
imageUrl={appInfo.icon_url}
/>
}
status={status}
statusLabel={statusLabel}
switchDisabled={!canEdit}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
actions={
<>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
onClick={() => setShowEmbedded(true)}
>
<span aria-hidden className="i-ri-window-line size-4" />
{t(($) => $['studio.accessPoint.embedIntoSite'], { ns: 'deployments' })}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
{t(($) => $['overview.appInfo.customize.entry'], {
ns: 'appOverview',
})}
</Button>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={availability !== 'available' || !canEdit}
onClick={() => setShowSettings(true)}
>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t(($) => $['settings.settings'], { ns: 'common' })}
</Button>
</>
}
>
<AccessPointUrl
label={t(($) => $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })}
value={webAppUrl}
enabled={running}
loading={availability === 'loading'}
unavailable={availability === 'unavailable'}
unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], {
ns: 'deployments',
})}
showOpen
showQrCode
showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
regenerateDisabled={!canEdit}
regenerating={regenerating}
onOpen={() => window.open(webAppUrl, '_blank')}
onRegenerate={() => setShowRegenerate(true)}
/>
{showAccessControl && (
<div className="px-4 pb-3">
{availability === 'available' ? (
<button
type="button"
className="flex h-9 w-full items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-components-input-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-components-input-bg-normal"
disabled={!canManageAccess}
onClick={() => setShowAccess(true)}
>
<span className="flex min-w-0 flex-1 items-center gap-x-1.5 pr-1">
<span
aria-hidden
className={`${accessIcon} size-4 shrink-0 text-text-tertiary`}
/>
<span className="truncate system-xs-regular text-text-tertiary">
{t(accessLabel, { ns: 'app' })}
</span>
</span>
{!accessConfigured && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
</span>
)}
<span
aria-hidden
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-quaternary"
/>
</button>
) : (
<div className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-components-input-bg-normal px-2.5">
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
<span className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
<span
aria-hidden
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
/>
</div>
)}
</div>
)}
</AccessPointCard>
<SettingsModal
isChat={appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW}
appInfo={appInfo}
isShow={showSettings}
onClose={() => setShowSettings(false)}
onSave={onSaveSiteConfig}
/>
<EmbeddedModal
siteInfo={appInfo.site}
isShow={showEmbedded}
onClose={() => setShowEmbedded(false)}
appBaseUrl={appInfo.site?.app_base_url}
accessToken={appInfo.site?.access_token}
hiddenInputs={hiddenInputs}
/>
<CustomizeModal
isShow={showCustomize}
onClose={() => setShowCustomize(false)}
appId={appInfo.id}
api_base_url={appInfo.api_base_url}
mode={appInfo.mode}
/>
{showAccess && (
<AccessControl
app={appInfo}
onClose={() => setShowAccess(false)}
onConfirm={async () => {
await onRefreshApp()
setShowAccess(false)
}}
/>
)}
<AlertDialog open={showRegenerate} onOpenChange={(open) => !open && setShowRegenerate(false)}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={() => void handleRegenerate()}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,113 @@
import type { MockEnvironmentDeployment } from '@/app/components/app/deploy/mock-data'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MOCK_PUBLISHED_VERSIONS } from '@/app/components/app/deploy/mock-data'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { PublisherEnvironmentFlow } from '../environment-deployment-flow'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.appMenus.deploy': 'Deploy',
'common.operation.back': 'Back',
'common.operation.cancel': 'Cancel',
'deployments.overview.chip.latest': 'Latest',
'deployments.studio.allVersions': 'All versions',
'deployments.studio.chooseVersionToDeploy': 'Choose a version to deploy',
'deployments.studio.current': 'Current',
'deployments.studio.deployConfiguration': 'Deploy configuration',
'deployments.studio.deployConfigurationDescription':
'Select credentials and complete environment variable values before deployment.',
'deployments.studio.deployLatest': 'Deploy latest',
'deployments.studio.deployOtherVersion': 'Deploy other version',
'deployments.versions.deployTo': 'Deploy to {{name}}',
'workflow.common.publishedBy': 'Published {{time}} by {{author}}',
})
})
const latestVersion = MOCK_PUBLISHED_VERSIONS.find((version) => version.latest)!
function createDeployment({
latest = false,
}: {
latest?: boolean
} = {}): MockEnvironmentDeployment {
return {
accessPoints: ['webApp'],
action: { kind: 'deployLatest' },
activity: {
actor: 'Evan',
occurredAt: Date.now(),
result: 'succeeded',
target: 'Sprint-42',
},
id: 'staging',
name: 'Staging',
status: 'running',
version: {
behind: latest ? undefined : 1,
latest,
name: 'Sprint-42',
publishedAt: Date.now(),
publishedBy: 'Evan',
},
}
}
function renderFlow(deployment = createDeployment()) {
return render(
<PublisherEnvironmentFlow
appId="app-1"
deployment={deployment}
environmentId={deployment.id}
environmentTabs={<div>Environment tabs</div>}
latestVersion={latestVersion}
onGoToPublish={vi.fn()}
/>,
)
}
describe('PublisherEnvironmentFlow', () => {
it('opens all versions and returns to the original publisher', async () => {
const user = userEvent.setup()
renderFlow()
await user.click(screen.getByRole('button', { name: 'All versions' }))
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByText('Environment tabs')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'All versions' })).toBeInTheDocument()
})
it('deploys the latest version directly and goes back to version selection', async () => {
const user = userEvent.setup()
renderFlow()
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument()
expect(screen.getByText(latestVersion.name)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
})
it('disables deploy latest and deploys another selected version when already latest', async () => {
const user = userEvent.setup()
renderFlow(createDeployment({ latest: true }))
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeDisabled()
expect(screen.getByText('Latest')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'All versions' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Deploy other version' }))
await user.click(screen.getByRole('button', { name: /Sprint-35/ }))
expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument()
expect(screen.getByText('Sprint-35')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,170 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { BUILT_IN_ENVIRONMENT_ID, PublisherEnvironmentTabs } from '../environment-tabs'
const environments = [
{ id: 'staging', name: 'Staging' },
{ id: 'pre-release', name: 'Pre-release' },
{ id: 'testing', name: 'Testing' },
{ id: 'demo', name: 'Demo' },
{ id: 'us-prod', name: 'US-Prod' },
] as const
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.operation.more': 'More',
'deployments.card.notDeployed': 'Not deployed',
'deployments.studio.environments': 'Environments',
'deployments.studio.moreEnvironments': 'More environments',
'workflow.nodes.common.memories.builtIn': 'Built-in',
})
})
function EnvironmentTabsHarness({
initialJoinedEnvironmentIds = [],
initialSelectedEnvironmentId = BUILT_IN_ENVIRONMENT_ID,
}: {
initialJoinedEnvironmentIds?: string[]
initialSelectedEnvironmentId?: string
}) {
const [joinedEnvironmentIds, setJoinedEnvironmentIds] = useState(initialJoinedEnvironmentIds)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(initialSelectedEnvironmentId)
return (
<PublisherEnvironmentTabs
environments={environments}
joinedEnvironmentIds={joinedEnvironmentIds}
selectedEnvironmentId={selectedEnvironmentId}
onAddEnvironment={(environmentId) => {
setJoinedEnvironmentIds((currentEnvironmentIds) => [
...currentEnvironmentIds,
environmentId,
])
setSelectedEnvironmentId(environmentId)
}}
onSelectEnvironment={setSelectedEnvironmentId}
/>
)
}
describe('PublisherEnvironmentTabs', () => {
it('uses More environments as the only add entry, then adds and selects an environment tab', async () => {
const user = userEvent.setup()
render(<EnvironmentTabsHarness />)
expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true')
await user.click(screen.getByRole('button', { name: 'More environments' }))
const menu = screen.getByRole('menu')
expect(within(menu).getByText('Not deployed')).toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Staging', 'Pre-release', 'Testing', 'Demo', 'US-Prod'])
await user.click(within(menu).getByRole('menuitem', { name: 'Staging' }))
expect(screen.getByRole('tab', { name: 'Staging' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument()
})
it('keeps fixed visible tabs in place and promotes a selected overflow environment', async () => {
const user = userEvent.setup()
render(
<EnvironmentTabsHarness
initialJoinedEnvironmentIds={['staging', 'pre-release', 'testing']}
/>,
)
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in',
'Staging',
'Pre-release',
])
await user.click(screen.getByRole('button', { name: 'More' }))
await user.click(screen.getByRole('menuitem', { name: 'Testing' }))
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in',
'Staging',
'Pre-release',
])
expect(screen.getByRole('button', { name: 'Testing' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Testing' }))
const menu = screen.getByRole('menu')
expect(within(menu).queryByRole('menuitem', { name: 'Testing' })).not.toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Demo', 'US-Prod'])
})
it('removes the not-deployed section when every environment has joined', async () => {
const user = userEvent.setup()
render(
<EnvironmentTabsHarness
initialJoinedEnvironmentIds={environments.map((environment) => environment.id)}
initialSelectedEnvironmentId="testing"
/>,
)
await user.click(screen.getByRole('button', { name: 'Testing' }))
const menu = screen.getByRole('menu')
expect(within(menu).queryByText('Not deployed')).not.toBeInTheDocument()
expect(
within(menu)
.getAllByRole('menuitem')
.map((item) => item.textContent),
).toEqual(['Demo', 'US-Prod'])
})
it('does not render More when every joined environment fits', () => {
render(
<PublisherEnvironmentTabs
environments={environments.slice(0, 2)}
joinedEnvironmentIds={['staging', 'pre-release']}
selectedEnvironmentId={BUILT_IN_ENVIRONMENT_ID}
onAddEnvironment={vi.fn()}
onSelectEnvironment={vi.fn()}
/>,
)
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
'Built-in',
'Staging',
'Pre-release',
])
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'More environments' })).not.toBeInTheDocument()
})
it('shows the full environment name in a tooltip when its tab is truncated', async () => {
const user = userEvent.setup()
const longEnvironment = {
id: 'long-production',
name: 'Production environment with a very long name',
}
render(
<PublisherEnvironmentTabs
environments={[longEnvironment]}
joinedEnvironmentIds={[longEnvironment.id]}
selectedEnvironmentId={longEnvironment.id}
onAddEnvironment={vi.fn()}
onSelectEnvironment={vi.fn()}
/>,
)
await user.hover(screen.getByRole('tab', { name: longEnvironment.name }))
expect(await screen.findByRole('tooltip')).toHaveTextContent(longEnvironment.name)
})
})

View File

@@ -57,6 +57,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({
}))
describe('FeaturesWrappedAppPublisher', () => {
const resetAppConfig = vi.fn()
const publishedConfig = {
modelConfig: {
more_like_this: { enabled: true },
@@ -81,7 +82,6 @@ describe('FeaturesWrappedAppPublisher', () => {
allowed_file_upload_methods: ['remote_url'],
number_limits: 5,
},
resetAppConfig: vi.fn(),
},
}
@@ -106,13 +106,18 @@ describe('FeaturesWrappedAppPublisher', () => {
})
it('should restore published features after confirmation', async () => {
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
resetAppConfig={resetAppConfig}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ }))
await waitFor(() => {
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
expect(resetAppConfig).toHaveBeenCalledTimes(1)
expect(mockSetFeatures).toHaveBeenCalledWith(
expect.objectContaining({
moreLikeThis: { enabled: true },
@@ -128,12 +133,18 @@ describe('FeaturesWrappedAppPublisher', () => {
citation: { enabled: true },
annotationReply: { enabled: true },
}),
{ silent: true },
)
})
})
it('should close restore confirmation without restoring when cancelled', async () => {
render(<FeaturesWrappedAppPublisher publishedConfig={publishedConfig as any} />)
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
resetAppConfig={resetAppConfig}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
const dialog = screen.getByRole('alertdialog')
@@ -145,7 +156,7 @@ describe('FeaturesWrappedAppPublisher', () => {
await waitFor(() => {
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled()
expect(resetAppConfig).not.toHaveBeenCalled()
expect(mockSetFeatures).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,8 @@
/* oxlint-disable typescript/no-explicit-any */
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { WorkflowContext } from '@/app/components/workflow/context'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -18,12 +20,14 @@ const mockSetAppDetail = vi.fn()
const mockTrackEvent = vi.fn()
const mockRefetch = vi.fn()
const mockUseGetUserCanAccessApp = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetail = vi.fn()
const mockToastError = vi.fn()
const mockToastSuccess = vi.fn()
const mockWindowOpen = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
const mockUpdateWorkflow = vi.fn()
const mockFetchPublishedWorkflow = vi.fn()
let mockPublishedWorkflow: Record<string, any> | null = null
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
@@ -34,9 +38,20 @@ const hotkeyMocks = vi.hoisted(() => ({
hotkeys: [] as string[],
handlers: [] as Array<(event: { preventDefault: () => void }) => void>,
}))
const collaborationMocks = vi.hoisted(() => ({
handler: undefined as
| ((update: {
type: 'app_publish_update'
userId: string
data: Record<string, unknown>
timestamp: number
}) => void)
| undefined,
}))
let mockAppDetail: Record<string, any> | null = null
let mockWorkspacePermissionKeys: string[] = ['tool.manage']
let mockIsCurrentWorkspaceEditor = true
vi.mock('@tanstack/react-hotkeys', () => ({
useHotkey: (hotkey: string, handler: (event: { preventDefault: () => void }) => void) => {
@@ -64,10 +79,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control/use-app-access-control', () => ({
useGetUserCanAccessApp: (params: unknown) => {
mockUseGetUserCanAccessApp(params)
@@ -83,10 +94,6 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({
}),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
}))
const mockPublishToCreatorsPlatform = vi.fn()
vi.mock('@/service/apps', () => ({
@@ -95,7 +102,22 @@ vi.mock('@/service/apps', () => ({
}))
vi.mock('@/service/use-workflow', () => ({
appWorkflowQueryOptions: (appId: string) => ({
queryKey: ['workflow', 'publish', appId],
queryFn: () => mockFetchPublishedWorkflow(appId),
}),
useAppWorkflow: () => ({ data: mockPublishedWorkflow, isSuccess: true }),
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
useUpdateWorkflow: () => ({ mutate: mockUpdateWorkflow }),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: {
onAppPublishUpdate: (handler: NonNullable<(typeof collaborationMocks)['handler']>) => {
collaborationMocks.handler = handler
return vi.fn()
},
},
}))
vi.mock('@/service/use-tools', () => ({
@@ -110,6 +132,7 @@ vi.mock('@/service/use-tools', () => ({
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
@@ -125,6 +148,7 @@ vi.mock('@/context/permission-state', async () => {
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
success: (...args: unknown[]) => mockToastSuccess(...args),
},
}))
@@ -132,16 +156,6 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) =>
isShow ? (
<div data-testid="embedded-modal">
embedded modal
<button onClick={onClose}>close-embedded-modal</button>
</div>
) : null,
}))
vi.mock('../../app-access-control', () => {
const MockAccessControl = ({
onConfirm,
@@ -172,14 +186,32 @@ vi.mock('@/app/components/tools/workflow-tool', () => ({
}))
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
const dropdownMenuMock = await import('@/__mocks__/base-ui-dropdown-menu')
return {
...dropdownMenuMock,
DropdownMenuGroup: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DropdownMenuLabel: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DropdownMenuSeparator: (props: React.HTMLAttributes<HTMLDivElement>) => (
<div role="separator" {...props} />
),
}
})
vi.mock('../sections', () => ({
PublisherTimelineMarker: () => <span data-testid="publisher-timeline-marker" />,
PublisherSummarySection: (props: Record<string, any>) => {
sectionProps.summary = props
return (
<div>
{props.environmentTabs}
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={props.onEditVersion}>publisher-summary-edit-version</button>
</div>
)
},
@@ -190,18 +222,18 @@ vi.mock('../sections', () => ({
PublisherActionsSection: (props: Record<string, any>) => {
sectionProps.actions = props
return (
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
{props.handleOpenRunConfig && (
<>
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
publisher-run-config
</button>
<button onClick={() => props.handleOpenRunConfig(`${props.appURL}?mode=batch`)}>
publisher-batch-run-config
</button>
</>
<div data-testid="publisher-actions">
{props.showRunConfig && props.handleOpenRunConfig && (
<button onClick={() => props.handleOpenRunConfig(props.appURL)}>
publisher-run-config
</button>
)}
{props.showMarketplaceAction && (
<button disabled={props.marketplaceActionDisabled} onClick={props.onPublishToMarketplace}>
{props.publishingToMarketplace
? 'workflow.common.publishingToMarketplace'
: 'workflow.common.publishToMarketplace'}
</button>
)}
<button onClick={props.onConfigureWorkflowTool}>publisher-workflow-tool</button>
</div>
@@ -214,10 +246,14 @@ describe('AppPublisher', () => {
vi.clearAllMocks()
hotkeyMocks.hotkeys.length = 0
hotkeyMocks.handlers.length = 0
collaborationMocks.handler = undefined
sectionProps.summary = null
sectionProps.access = null
sectionProps.actions = null
mockPublishedWorkflow = null
mockFetchPublishedWorkflow.mockResolvedValue(null)
mockWorkspacePermissionKeys = ['tool.manage']
mockIsCurrentWorkspaceEditor = true
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
@@ -228,17 +264,12 @@ describe('AppPublisher', () => {
access_token: 'token-1',
},
}
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetail.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
return resolver()
})
Object.defineProperty(window, 'open', {
configurable: true,
writable: true,
value: mockWindowOpen,
})
@@ -282,16 +313,114 @@ describe('AppPublisher', () => {
})
})
it('should open the embedded modal from the actions section', () => {
it('should edit the current workflow version from the publish summary', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockPublishedWorkflow = {
created_at: Math.floor(Date.now() / 1000),
id: 'workflow-version-5',
marked_name: 'Release 5',
marked_comment: 'Initial notes',
}
render(<AppPublisher publishedAt={Date.now()} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-embed'))
expect(sectionProps.summary).toEqual(
expect.objectContaining({
isWorkflowApp: true,
versionInfo: mockPublishedWorkflow,
}),
)
fireEvent.click(screen.getByText('publisher-summary-edit-version'))
expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument()
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput!, { target: { value: 'Release 6' } })
fireEvent.change(notesInput!, { target: { value: 'Updated notes' } })
const publishButtons = screen.getAllByRole('button', {
name: /(?:^|\.)common\.publish(?=$|:)/,
})
fireEvent.click(publishButtons.at(-1)!)
expect(mockUpdateWorkflow).toHaveBeenCalledWith(
{
url: '/apps/app-1/workflows/workflow-version-5',
title: 'Release 6',
releaseNotes: 'Updated notes',
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
onSettled: expect.any(Function),
}),
)
const mutationCallbacks = mockUpdateWorkflow.mock.calls[0]![1]
mutationCallbacks.onSuccess()
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
expect(mockToastSuccess).toHaveBeenCalled()
})
it('should collect hidden inputs before opening published run links from config actions', async () => {
it('should expose the Deploy quick link for editable workflow apps when enabled', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
renderWithConsoleQuery(<AppPublisher publishedAt={Date.now()} />, {
systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true },
})
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions?.showDeployAction).toBe(true)
expect(sectionProps.actions?.appURL).toContain('/workflow/token-1')
expect(
screen.getByRole('tab', { name: /nodes\.common\.memories\.builtIn/ }),
).toBeInTheDocument()
})
it('should keep the single-environment publisher for unsupported app types', () => {
renderWithConsoleQuery(<AppPublisher publishedAt={Date.now()} />, {
systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true },
})
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
expect(sectionProps.summary?.environmentTabs).toBeUndefined()
})
it('should switch between the built-in publisher and an overflow environment', async () => {
const user = userEvent.setup()
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
renderWithConsoleQuery(<AppPublisher publishedAt={Date.now()} />, {
systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true },
})
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
await user.click(
screen.getByRole('button', {
name: /(?:studio\.moreEnvironments|operation\.more)/,
}),
)
await user.click(screen.getByRole('menuitem', { name: 'Pre-release' }))
expect(screen.getByRole('button', { name: /studio\.deployLatest/ })).toBeInTheDocument()
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /nodes\.common\.memories\.builtIn/ }))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
})
it('should collect hidden inputs before opening the web app from its config action', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
@@ -309,6 +438,8 @@ describe('AppPublisher', () => {
)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions?.showRunConfig).toBe(true)
fireEvent.click(screen.getByText('publisher-run-config'))
expect(
@@ -330,55 +461,21 @@ describe('AppPublisher', () => {
})
})
it('should open batch run config links with the configured hidden inputs', async () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(
<AppPublisher
publishedAt={Date.now()}
inputs={[
{
variable: 'batch_secret',
label: 'Batch Secret',
type: 'text-input',
required: true,
hide: true,
default: '',
} as any,
]}
/>,
)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-batch-run-config'))
fireEvent.change(screen.getByLabelText('Batch Secret'), {
target: { value: 'batch-value' },
})
fireEvent.click(
screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }),
)
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
`https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`,
'_blank',
)
})
})
it('should keep workflow tool drawer mounted after closing the publish popover', () => {
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
render(<AppPublisher publishedAt={Date.now()} />)
render(<AppPublisher publishedAt={Date.now()} toolPublished />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions).toEqual(
expect.objectContaining({
toolPublished: true,
workflowToolOutdated: false,
}),
)
fireEvent.click(screen.getByText('publisher-workflow-tool'))
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
@@ -401,14 +498,9 @@ describe('AppPublisher', () => {
expect(sectionProps.actions?.workflowToolAvailable).toBe(false)
})
it('should close embedded and access control panels through child callbacks', async () => {
it('should close access control through its child callback', async () => {
render(<AppPublisher publishedAt={Date.now()} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-embed'))
fireEvent.click(screen.getByText('close-embedded-modal'))
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-access-control'))
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
@@ -443,25 +535,6 @@ describe('AppPublisher', () => {
)
})
it('should open the installed explore page through the async window helper', async () => {
let openedUrl = ''
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
openedUrl = await resolver()
})
render(<AppPublisher publishedAt={Date.now()} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
expect(openedUrl).toBe('/installed/installed-1')
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
})
})
it('should ignore the trigger when the publish button is disabled', () => {
render(<AppPublisher disabled publishedAt={Date.now()} onToggle={mockOnToggle} />)
@@ -479,8 +552,13 @@ describe('AppPublisher', () => {
const onRestore = vi.fn().mockResolvedValue(undefined)
mockOnPublish.mockResolvedValue(undefined)
render(
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
const { rerender } = render(
<AppPublisher
hasUnpublishedChanges
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P')
@@ -491,6 +569,30 @@ describe('AppPublisher', () => {
expect(mockOnPublish).toHaveBeenCalledTimes(1)
})
rerender(
<AppPublisher
hasUnpublishedChanges={false}
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
hotkeyMocks.handlers.at(-1)!({ preventDefault })
expect(mockOnPublish).toHaveBeenCalledTimes(1)
rerender(
<AppPublisher
hasUnpublishedChanges
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
hotkeyMocks.handlers.at(-1)!({ preventDefault })
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledTimes(2)
})
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-summary-restore'))
@@ -500,13 +602,44 @@ describe('AppPublisher', () => {
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
it('should require an explicit model selection when publishing in multiple model mode', () => {
const preventDefault = vi.fn()
render(
<AppPublisher
debugWithMultipleModel
hasUnpublishedChanges
multipleModelConfigs={[
{
id: 'model-1',
model: 'gpt-4o',
provider: 'openai',
parameters: {},
},
]}
publishedAt={Date.now()}
onPublish={mockOnPublish}
/>,
)
hotkeyMocks.handlers[0]!({ preventDefault })
expect(preventDefault).not.toHaveBeenCalled()
expect(mockOnPublish).not.toHaveBeenCalled()
})
it('should keep the popover open when restore and publish fail', async () => {
const preventDefault = vi.fn()
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
render(
<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} onRestore={onRestore} />,
<AppPublisher
hasUnpublishedChanges
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
hotkeyMocks.handlers[0]!({ preventDefault })
@@ -526,57 +659,6 @@ describe('AppPublisher', () => {
expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument()
})
it('should report missing explore installations', async () => {
mockFetchInstalledAppList.mockResolvedValueOnce({
installed_apps: [],
})
mockOpenAsyncWindow.mockImplementation(
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
} catch (error) {
options.onError(error as Error)
}
},
)
render(<AppPublisher publishedAt={Date.now()} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith(
expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/),
)
})
})
it('should report explore errors when the app cannot be opened', async () => {
mockAppDetail = {
...mockAppDetail,
id: undefined,
}
mockOpenAsyncWindow.mockImplementation(
async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
} catch (error) {
options.onError(error as Error)
}
},
)
render(<AppPublisher publishedAt={Date.now()} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('App not found')
})
})
it('should show marketplace button and open redirect URL on success', async () => {
mockPublishToCreatorsPlatform.mockResolvedValue({
redirect_url: 'https://marketplace.example.com/publish?code=abc',
@@ -588,6 +670,12 @@ describe('AppPublisher', () => {
})
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions).toEqual(
expect.objectContaining({
marketplaceActionDisabled: false,
showMarketplaceAction: true,
}),
)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/))
await waitFor(() => {
@@ -624,6 +712,12 @@ describe('AppPublisher', () => {
})
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions).toEqual(
expect.objectContaining({
marketplaceActionDisabled: true,
showMarketplaceAction: true,
}),
)
const marketplaceButton = screen
.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)
.closest('a, button, div[role="button"]') as HTMLElement
@@ -637,6 +731,7 @@ describe('AppPublisher', () => {
render(<AppPublisher publishedAt={Date.now()} onPublish={mockOnPublish} />)
fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.actions?.showMarketplaceAction).toBe(false)
expect(
screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/),
).not.toBeInTheDocument()
@@ -656,4 +751,116 @@ describe('AppPublisher', () => {
})
expect(screen.getByTestId('access-control'))!.toBeInTheDocument()
})
it('should not infer an app mode when app detail is unavailable', async () => {
const user = userEvent.setup()
mockAppDetail = null
render(<AppPublisher publishedAt={Date.now()} />)
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.summary).toEqual(
expect.objectContaining({
isChatApp: false,
isWorkflowApp: false,
}),
)
})
it('should derive workflow changes from draft and published hashes', async () => {
const user = userEvent.setup()
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockPublishedWorkflow = {
created_at: 1_710_000_100,
hash: 'published-hash',
}
const { rerender } = render(
<AppPublisher
draftHash="published-hash"
draftUpdatedAt={1_710_000_200_000}
publishedAt={1_710_000_100_000}
/>,
)
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(false)
rerender(
<AppPublisher
draftHash="changed-draft-hash"
draftUpdatedAt={1_710_000_100_000}
publishedAt={1_710_000_200_000}
/>,
)
expect(sectionProps.summary?.hasUnpublishedChanges).toBe(true)
})
it('should keep workflow publishing available when the published hash is unavailable', async () => {
const user = userEvent.setup()
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockPublishedWorkflow = {
created_at: 1_710_000_100,
hash: '',
}
render(
<AppPublisher
draftHash="draft-hash"
draftUpdatedAt={1_710_000_200_000}
publishedAt={1_710_000_100_000}
/>,
)
await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/))
expect(sectionProps.summary).toEqual(
expect.objectContaining({
hasUnpublishedChanges: true,
publishedAt: 1_710_000_100_000,
}),
)
})
it('should refresh the shared workflow query and store after a collaborator publishes', async () => {
const setPublishedAt = vi.fn()
const workflowStore = {
getState: () => ({ setPublishedAt }),
}
mockAppDetail = {
...mockAppDetail,
mode: AppModeEnum.WORKFLOW,
}
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 1_710_000_300,
hash: 'published-hash',
})
render(
<WorkflowContext value={workflowStore as any}>
<AppPublisher draftHash="draft-hash" publishedAt={1_710_000_100_000} />
</WorkflowContext>,
)
act(() => {
collaborationMocks.handler?.({
type: 'app_publish_update',
userId: 'collaborator-1',
data: { action: 'published' },
timestamp: 1_710_000_300_000,
})
})
await waitFor(() => {
expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('app-1')
expect(setPublishedAt).toHaveBeenCalledWith(1_710_000_300)
})
})
})

View File

@@ -143,6 +143,27 @@ describe('PublishWithMultipleModel', () => {
expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument()
})
it('should disable the trigger when publishing is unavailable', () => {
render(
<PublishWithMultipleModel
disabled
multipleModelConfigs={[
{
id: 'config-1',
provider: 'openai',
model: 'gpt-4o',
parameters: {},
},
]}
onSelect={vi.fn()}
/>,
)
expect(
screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }),
).toBeDisabled()
})
it('should open matching model options and call onSelect', () => {
const handleSelect = vi.fn()
const modelConfig = {

View File

@@ -1,6 +1,7 @@
/* oxlint-disable typescript/no-explicit-any */
import type { ReactNode } from 'react'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessMode } from '@/models/access-control'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -12,45 +13,19 @@ import {
} from '../sections'
vi.mock('../publish-with-multiple-model', () => ({
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>
default: ({
disabled,
onSelect,
}: {
disabled?: boolean
onSelect: (item: Record<string, unknown>) => void
}) => (
<button type="button" disabled={disabled} onClick={() => onSelect({ model: 'gpt-4o' })}>
publish-multiple-model
</button>
),
}))
vi.mock('../suggested-action', () => ({
default: ({
children,
onClick,
link,
disabled,
actionButton,
}: {
children: ReactNode
onClick?: () => void
link?: string
disabled?: boolean
actionButton?: { ariaLabel: string; onClick: () => void }
}) => (
<div>
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>
{children}
</button>
{actionButton && (
<button
type="button"
aria-label={actionButton.ariaLabel}
disabled={disabled}
onClick={actionButton.onClick}
>
{actionButton.ariaLabel}
</button>
)}
</div>
),
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: (props: Record<string, unknown>) => (
<div>
@@ -60,6 +35,32 @@ vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
),
}))
const createVersionInfo = (overrides: Partial<VersionHistory> = {}): VersionHistory => ({
id: 'workflow-version-1',
graph: {
nodes: [],
edges: [],
},
created_at: 1_710_000_000,
created_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
hash: 'hash-1',
updated_at: 1_710_000_000,
updated_by: {
id: 'user-1',
name: 'Alice',
email: 'alice@example.com',
},
tool_published: false,
version: '2024-03-09T16:00:00Z',
marked_name: '',
marked_comment: '',
...overrides,
})
describe('app-publisher sections', () => {
it('should render restore controls for published chat apps', () => {
const handleRestore = vi.fn()
@@ -71,10 +72,10 @@ describe('app-publisher sections', () => {
formatTimeFromNow={() => '3 minutes ago'}
handlePublish={vi.fn()}
handleRestore={handleRestore}
hasUnpublishedChanges
isChatApp
multipleModelConfigs={[]}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
@@ -85,6 +86,35 @@ describe('app-publisher sections', () => {
expect(handleRestore).toHaveBeenCalled()
})
it('should disable restore for published chat apps without unpublished changes', async () => {
const user = userEvent.setup()
const handleRestore = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '3 minutes ago'}
handlePublish={vi.fn()}
handleRestore={handleRestore}
hasUnpublishedChanges={false}
isChatApp
multipleModelConfigs={[]}
publishDisabled={false}
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
const restoreButton = screen.getByRole('button', {
name: /(?:^|\.)common\.restore(?=$|:)/,
})
expect(restoreButton).toBeDisabled()
await user.click(restoreButton)
expect(handleRestore).not.toHaveBeenCalled()
})
it('should expose the access control warning when subjects are missing', () => {
render(
<PublisherAccessSection
@@ -100,7 +130,7 @@ describe('app-publisher sections', () => {
expect(screen.getByText(/(?:^|\.)publishApp\.notSetDesc(?=$|:)/)).toBeInTheDocument()
})
it('should render the publish update action when the draft has not been published yet', () => {
it('should render the initial publish action when the draft has not been published yet', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
@@ -111,17 +141,118 @@ describe('app-publisher sections', () => {
isChatApp={false}
multipleModelConfigs={[]}
publishDisabled={false}
published={false}
publishedAt={undefined}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.notPublishedYet(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)).toBeInTheDocument()
expect(screen.getByText('P')).toBeInTheDocument()
expect(screen.getByText(/(?:^|\.)common\.unpublishedChanges(?=$|:)/)).toBeInTheDocument()
})
it('should render multiple-model publishing', () => {
it('should expose naming for an unnamed published workflow with no draft changes', () => {
const onEditVersion = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_000_000}
formatTimeFromNow={() => '17 days ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
hasUnpublishedChanges={false}
isChatApp={false}
isWorkflowApp
multipleModelConfigs={[]}
onEditVersion={onEditVersion}
publishDisabled={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
versionInfo={createVersionInfo()}
/>,
)
expect(screen.getByText('2024-03-09T16:00:00Z')).toBeInTheDocument()
const nameButton = screen.getByRole('button', {
name: /versionHistory\.nameIt\b/,
})
fireEvent.click(nameButton)
expect(onEditVersion).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /common\.published\b/ })).toBeDisabled()
expect(screen.queryByText('P')).not.toBeInTheDocument()
expect(screen.getByText(/common\.noChanges\b/)).toBeInTheDocument()
})
it('should show named workflow metadata and publish update when its draft changed', () => {
const onEditVersion = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_200_000}
formatTimeFromNow={() => '2 minutes ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
hasUnpublishedChanges
isChatApp={false}
isWorkflowApp
multipleModelConfigs={[]}
onEditVersion={onEditVersion}
publishDisabled={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
versionInfo={createVersionInfo({
marked_name: 'Sprint-42',
marked_comment: 'Fixed data synchronization and page loading.',
})}
/>,
)
expect(screen.getByText('Sprint-42')).toBeInTheDocument()
expect(screen.getByText('Fixed data synchronization and page loading.')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: /versionHistory\.editVersionInfo\b/,
}),
)
expect(onEditVersion).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled()
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
expect(screen.getByText(/common\.savedAt\b/)).toBeInTheDocument()
})
it('should keep non-workflow apps free of workflow version details and saved time', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={1_710_000_200_000}
formatTimeFromNow={() => '2 minutes ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
hasUnpublishedChanges
isChatApp
isWorkflowApp={false}
multipleModelConfigs={[]}
publishDisabled={false}
publishedAt={1_710_000_100_000}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getByText(/common\.latestPublished\b/)).toBeInTheDocument()
expect(screen.queryByText('#5')).not.toBeInTheDocument()
expect(screen.queryByText(/versionHistory\.nameIt\b/)).not.toBeInTheDocument()
expect(screen.getByText(/common\.unpublishedChanges\b/)).toBeInTheDocument()
expect(screen.queryByText(/common\.savedAt\b/)).not.toBeInTheDocument()
})
it('should keep multiple-model publishing available without publish config changes', () => {
const handlePublish = vi.fn()
render(
@@ -131,11 +262,11 @@ describe('app-publisher sections', () => {
formatTimeFromNow={() => '1 minute ago'}
handlePublish={handlePublish}
handleRestore={vi.fn()}
hasUnpublishedChanges={false}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
@@ -146,6 +277,27 @@ describe('app-publisher sections', () => {
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
})
it('should disable multiple-model publishing when publishing is unavailable', () => {
render(
<PublisherSummarySection
debugWithMultipleModel
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
hasUnpublishedChanges={false}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
publishDisabled
publishedAt={Date.now()}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
/>,
)
expect(screen.getByRole('button', { name: 'publish-multiple-model' })).toBeDisabled()
})
it('should render the upgrade hint when the start node limit is exceeded', () => {
render(
<PublisherSummarySection
@@ -157,7 +309,6 @@ describe('app-publisher sections', () => {
isChatApp={false}
multipleModelConfigs={[]}
publishDisabled={false}
published={false}
publishedAt={undefined}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
@@ -213,12 +364,13 @@ describe('app-publisher sections', () => {
).not.toBeInTheDocument()
})
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
const handleOpenInExplore = vi.fn()
const handleEmbed = vi.fn()
it('should render the published workflow actions with Workflow as Tool after Marketplace', async () => {
const user = userEvent.setup()
const handleOpenRunConfig = vi.fn()
const onConfigureWorkflowTool = vi.fn()
const onPublishToMarketplace = vi.fn()
const { rerender } = render(
render(
<PublisherActionsSection
appDetail={{
id: 'workflow-app',
@@ -232,92 +384,280 @@ describe('app-publisher sections', () => {
appURL="https://example.com/app"
disabledFunctionButton={false}
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode={false}
published={false}
publishedAt={Date.now()}
showBatchRunConfig
showDeployAction
showMarketplaceAction
showRunConfig
toolPublished
workflowToolAvailable={false}
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
workflowToolMessage="workflow-disabled"
onConfigureWorkflowTool={vi.fn()}
onPublishToMarketplace={onPublishToMarketplace}
onConfigureWorkflowTool={onConfigureWorkflowTool}
/>,
)
expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute(
'data-link',
'https://example.com/app?mode=batch',
expect(screen.getByRole('link', { name: /common\.openWebApp\b/ })).toHaveAttribute(
'href',
'https://example.com/app',
)
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!)
fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ }))
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app')
fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!)
expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch')
fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/))
expect(handleOpenInExplore).toHaveBeenCalled()
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
'href',
'/app/workflow-app/access-point',
)
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
'href',
'/app/workflow-app/deploy',
)
rerender(
const marketplaceAction = screen.getByRole('button', {
name: /common\.publishToMarketplace\b/,
})
const workflowToolAction = screen.getByRole('button', {
name: /common\.workflowAsTool\b/,
})
expect(
marketplaceAction.compareDocumentPosition(workflowToolAction) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
expect(screen.getByRole('status', { name: /common\.configureRequired\b/ })).toBeInTheDocument()
await user.click(marketplaceAction)
expect(onPublishToMarketplace).toHaveBeenCalledTimes(1)
await user.click(workflowToolAction)
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
})
it('should expose Configure and Manage in Tools actions for a ready workflow tool', async () => {
const user = userEvent.setup()
const onConfigureWorkflowTool = vi.fn()
render(
<PublisherActionsSection
appDetail={{
id: 'chat-app',
mode: AppModeEnum.CHAT,
name: 'Chat App',
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow App',
}}
appURL="https://example.com/app?foo=bar"
disabledFunctionButton
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
appURL="https://example.com/app"
disabledFunctionButton={false}
hasHumanInputNode={false}
hasTriggerNode={false}
missingStartNode
published={false}
publishedAt={Date.now()}
toolPublished={false}
showDeployAction
toolPublished
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
onConfigureWorkflowTool={vi.fn()}
onConfigureWorkflowTool={onConfigureWorkflowTool}
/>,
)
fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/))
expect(handleEmbed).toHaveBeenCalled()
expect(screen.getByText(/(?:^|\.)common\.accessAPIReference(?=$|:)/)).toBeDisabled()
expect(
screen.getByRole('status', { name: /common\.workflowAsToolReady\b/ }),
).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common\.manageInTools\b/ })).toHaveAttribute(
'href',
'/integrations/tools/workflow',
)
await user.click(screen.getByRole('button', { name: /common\.configure\b/ }))
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
})
it('should show the disabled reason below setup and configured workflow tool actions', () => {
const commonProps = {
appDetail: {
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
},
appURL: 'https://example.com/app',
disabledFunctionButton: false,
hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool: vi.fn(),
publishedAt: Date.now(),
workflowToolAvailable: false,
workflowToolIsLoading: false,
workflowToolMessage: 'Workflow tool unavailable',
}
const { rerender } = render(<PublisherActionsSection {...commonProps} toolPublished={false} />)
const setupAction = screen.getByRole('button', { name: /common\.workflowAsTool\b/ })
const setupReason = screen.getByText('Workflow tool unavailable')
expect(setupAction).toBeDisabled()
expect(setupReason).toBeVisible()
expect(
setupAction.compareDocumentPosition(setupReason) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
rerender(<PublisherActionsSection {...commonProps} toolPublished />)
const configureAction = screen.getByRole('button', { name: /common\.configure\b/ })
const manageAction = screen.getByRole('button', { name: /common\.manageInTools\b/ })
const configuredReason = screen.getByText('Workflow tool unavailable')
expect(configureAction).toBeDisabled()
expect(manageAction).toBeDisabled()
expect(configuredReason).toBeVisible()
expect(
manageAction.compareDocumentPosition(configuredReason) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('should surface update-needed and loading states for a configured workflow tool', async () => {
const user = userEvent.setup()
const onConfigureWorkflowTool = vi.fn()
const commonProps = {
appDetail: {
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
},
appURL: 'https://example.com/app',
disabledFunctionButton: false,
hasHumanInputNode: false,
hasTriggerNode: false,
onConfigureWorkflowTool,
publishedAt: Date.now(),
toolPublished: true,
workflowToolAvailable: true,
}
const { rerender } = render(
<PublisherActionsSection
{...commonProps}
workflowToolIsLoading={false}
workflowToolOutdated
/>,
)
expect(
screen.getByRole('status', { name: /common\.workflowAsToolUpdateNeeded\b/ }),
).toBeInTheDocument()
expect(screen.getByText(/common\.workflowAsToolTip\b/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /common\.workflowAsToolReconfigure\b/ }))
expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1)
rerender(
<PublisherActionsSection
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
{...commonProps}
workflowToolIsLoading
workflowToolOutdated={false}
/>,
)
expect(screen.getByRole('button', { name: /common\.workflowAsTool\b/ })).toBeDisabled()
expect(screen.getByRole('status', { name: /loading\b/ })).toBeInTheDocument()
expect(screen.queryByText(/common\.workflowAsToolTip\b/)).not.toBeInTheDocument()
})
it('should keep Access Point and Deploy available for trigger workflows', () => {
render(
<PublisherActionsSection
appDetail={{
id: 'trigger-app',
mode: AppModeEnum.WORKFLOW,
}}
appURL="https://example.com/app"
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handleOpenRunConfig={handleOpenRunConfig}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
missingStartNode={false}
published={false}
publishedAt={undefined}
toolPublished={false}
publishedAt={Date.now()}
showDeployAction
workflowToolAvailable
workflowToolIsLoading={false}
workflowToolOutdated={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
expect(screen.queryByText(/(?:^|\.)common\.runApp(?=$|:)/)).not.toBeInTheDocument()
expect(screen.queryByText(/(?:^|\.)common\.openWebApp(?=$|:)/)).not.toBeInTheDocument()
expect(screen.queryByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/)).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute(
'href',
'/app/trigger-app/access-point',
)
expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute(
'href',
'/app/trigger-app/deploy',
)
})
it('should expose unavailable quick links as disabled buttons before the first publish', () => {
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
showDeployAction
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
expect(screen.getByText(/(?:^|\.)common\.openWebApp(?=$|:)/).closest('button')).toBeDisabled()
expect(
screen.getByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/).closest('button'),
).toBeDisabled()
expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/).closest('button')).toBeDisabled()
expect(
screen.getByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/).closest('button'),
).toBeDisabled()
})
it('should show the disabled reason when hovering an unavailable action', async () => {
const user = userEvent.setup()
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
disabledFunctionTooltip="Open web app unavailable"
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
await user.hover(screen.getByRole('button', { name: /common\.openWebApp\b/ }))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
})
it('should keep an unavailable action with a tooltip keyboard focusable', async () => {
const user = userEvent.setup()
render(
<PublisherActionsSection
appDetail={{ id: 'workflow-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton
disabledFunctionTooltip="Open web app unavailable"
hasHumanInputNode={false}
hasTriggerNode={false}
publishedAt={undefined}
workflowToolAvailable
workflowToolIsLoading={false}
onConfigureWorkflowTool={vi.fn()}
/>,
)
await user.tab()
const action = screen.getByRole('button', { name: /common\.openWebApp\b/ })
expect(action).toHaveFocus()
expect(action).toHaveAttribute('aria-disabled', 'true')
expect(action).toHaveAccessibleDescription('Open web app unavailable')
expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable')
})
})

View File

@@ -1,79 +1,133 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SuggestedAction from '../suggested-action'
describe('SuggestedAction', () => {
it('should render an enabled external link', () => {
render(<SuggestedAction link="https://example.com/docs">Open docs</SuggestedAction>)
it('should render an enabled external link with supporting copy', () => {
render(
<SuggestedAction
link="https://example.com/docs"
external
description="Read the documentation"
>
Open docs
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Open docs' })
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAccessibleDescription('Read the documentation')
expect(screen.getByText('Read the documentation')).toBeInTheDocument()
})
it('should block clicks when disabled', () => {
it('should render internal destinations without opening a new tab', () => {
render(
<SuggestedAction link="/app/app-1/deploy" description="Push versions to environments">
Deploy
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Deploy' })
expect(link).toHaveAttribute('href', '/app/app-1/deploy')
expect(link).not.toHaveAttribute('target')
expect(link).toHaveAccessibleDescription('Push versions to environments')
})
it('should use native disabled button semantics for unavailable links', () => {
const handleClick = vi.fn()
render(
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
Disabled action
</SuggestedAction>,
)
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
fireEvent.click(link)
expect(link).not.toHaveAttribute('href')
expect(handleClick).not.toHaveBeenCalled()
})
it('should forward click events when enabled', () => {
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
})
render(
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
Enabled action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should render and trigger the trailing action button when configured', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
disabled
description="Unavailable until published"
onClick={handleClick}
>
Disabled action
</SuggestedAction>,
)
const action = screen.getByRole('button', { name: 'Disabled action' })
fireEvent.click(action)
expect(action).toBeDisabled()
expect(screen.queryByRole('link', { name: /Disabled action/ })).not.toBeInTheDocument()
expect(handleClick).not.toHaveBeenCalled()
})
it('should keep an explained disabled action in the keyboard tab order', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(
<SuggestedAction disabled focusableWhenDisabled onClick={handleClick}>
Disabled action with explanation
</SuggestedAction>,
)
await user.tab()
const action = screen.getByRole('button', { name: 'Disabled action with explanation' })
expect(action).toHaveFocus()
expect(action).toHaveAttribute('aria-disabled', 'true')
await user.keyboard('{Enter}')
expect(handleClick).not.toHaveBeenCalled()
})
it('should render and trigger an enabled button action', () => {
const handleClick = vi.fn()
render(
<SuggestedAction
description="Use as a tool in other apps"
endIcon={<span data-testid="configure-icon" />}
onClick={handleClick}
>
Workflow as Tool
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' }))
expect(handleClick).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('configure-icon')).toBeInTheDocument()
})
it('should keep the main link separate from a trailing action button', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/app"
external
actionButton={{
ariaLabel: 'Configure action',
icon: <span>config</span>,
onClick: handleActionClick,
}}
>
Configurable action
Open web app
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Open web app' })).toHaveAttribute(
'href',
'https://example.com/docs',
'https://example.com/app',
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(handleActionClick).toHaveBeenCalledTimes(1)
})
it('should block action button clicks when disabled', () => {
it('should disable both controls when an action with a trailing button is unavailable', () => {
const handleActionClick = vi.fn()
render(
<SuggestedAction
link="https://example.com/docs"
link="https://example.com/app"
external
disabled
actionButton={{
ariaLabel: 'Configure action',
@@ -81,11 +135,15 @@ describe('SuggestedAction', () => {
onClick: handleActionClick,
}}
>
Disabled with action
Open web app
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('button', { name: 'Configure action' }))
expect(screen.getByRole('button', { name: 'Open web app' })).toBeDisabled()
const actionButton = screen.getByRole('button', { name: 'Configure action' })
fireEvent.click(actionButton)
expect(actionButton).toBeDisabled()
expect(handleActionClick).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
type ActionTooltipProps = {
children: ReactNode
disabled: boolean
tooltip?: ReactNode
}
const ActionTooltip = ({ children, disabled, tooltip }: ActionTooltipProps) => {
if (!tooltip) return <>{children}</>
return (
<Tooltip>
<TooltipTrigger
render={
<div
className={cn('flex w-full', disabled && 'cursor-not-allowed *:pointer-events-none')}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent role="tooltip">{tooltip}</TooltipContent>
</Tooltip>
)
}
export default ActionTooltip

View File

@@ -0,0 +1,210 @@
'use client'
import type { ReactNode } from 'react'
import type { MockEnvironmentDeployment, MockVersion } from '@/app/components/app/deploy/mock-data'
import { Button } from '@langgenius/dify-ui/button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeploymentConfigurationContent } from '@/app/components/app/deploy/deployment-dialog/deployment-configuration'
import { VersionChoice } from '@/app/components/app/deploy/deployment-dialog/version-selection'
import { MOCK_PUBLISHED_VERSIONS } from '@/app/components/app/deploy/mock-data'
import {
PublisherEnvironmentActionsSection,
PublisherEnvironmentSummarySection,
} from './environment-sections'
type PublisherEnvironmentView = 'configuration' | 'publisher' | 'versions'
type PublisherEnvironmentFlowProps = {
appId?: string
deployment?: MockEnvironmentDeployment
environmentId: string
environmentTabs: ReactNode
latestVersion?: MockVersion
onGoToPublish: () => void
}
function PublisherBackButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation('common')
return (
<Button
type="button"
size="small"
variant="ghost-accent"
className="-ml-1 h-6 gap-1 px-1 system-xs-semibold-uppercase"
onClick={onClick}
>
<span aria-hidden className="i-ri-arrow-left-line size-4" />
{t(($) => $['operation.back'])}
</Button>
)
}
function PublisherVersionSelection({
currentVersion,
environmentName,
onBack,
onSelect,
}: {
currentVersion?: string
environmentName: string
onBack: () => void
onSelect: (version: MockVersion) => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex min-h-0 flex-1 flex-col">
<header className="shrink-0 px-3 pt-3.5 pb-1">
<PublisherBackButton onClick={onBack} />
<h2 className="mt-0.5 px-1 system-xl-semibold text-text-primary">
{t(($) => $['versions.deployTo'], { name: environmentName })}
</h2>
<p className="mt-0.5 px-1 system-xs-regular text-text-tertiary">
{t(($) => $['studio.chooseVersionToDeploy'])}
</p>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<div className="flex flex-col gap-px">
{MOCK_PUBLISHED_VERSIONS.map((version) => (
<VersionChoice
key={version.name}
version={version}
current={version.name === currentVersion}
onSelect={onSelect}
/>
))}
</div>
</div>
</div>
)
}
function PublisherDeploymentConfiguration({
currentVersion,
environmentName,
version,
onBack,
onCancel,
onDeploy,
}: {
currentVersion?: string
environmentName: string
version: MockVersion
onBack: () => void
onCancel: () => void
onDeploy: () => void
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
const request = {
currentVersion,
environment: environmentName,
kind: 'deploy' as const,
}
return (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault()
onDeploy()
}}
>
<header className="shrink-0 px-3 pt-3.5 pb-1">
<PublisherBackButton onClick={onBack} />
<h2 className="mt-0.5 px-1 system-xl-semibold text-text-primary">
{t(($) => $['studio.deployConfiguration'])}
</h2>
<p className="mt-0.5 px-1 system-xs-regular text-text-tertiary">
{t(($) => $['studio.deployConfigurationDescription'])}
</p>
</header>
<DeploymentConfigurationContent
key={version.name}
compact
request={request}
version={version}
/>
<footer className="flex shrink-0 justify-end gap-2 px-4 pt-2 pb-4">
<Button type="button" variant="secondary" onClick={onCancel}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary">
{tCommon(($) => $['appMenus.deploy'])}
</Button>
</footer>
</form>
)
}
export function PublisherEnvironmentFlow({
appId,
deployment,
environmentId,
environmentTabs,
latestVersion,
onGoToPublish,
}: PublisherEnvironmentFlowProps) {
const [view, setView] = useState<PublisherEnvironmentView>('publisher')
const [selectedVersion, setSelectedVersion] = useState<MockVersion>()
const environmentName = deployment?.name ?? environmentId
const currentVersion = deployment?.version?.name
function openVersionSelection() {
setView('versions')
}
function openConfiguration(version: MockVersion) {
setSelectedVersion(version)
setView('configuration')
}
if (view === 'configuration' && selectedVersion) {
return (
<PublisherDeploymentConfiguration
currentVersion={currentVersion}
environmentName={environmentName}
version={selectedVersion}
onBack={openVersionSelection}
onCancel={() => setView('publisher')}
onDeploy={() => setView('publisher')}
/>
)
}
if (view !== 'publisher') {
return (
<PublisherVersionSelection
currentVersion={currentVersion}
environmentName={environmentName}
onBack={() => setView('publisher')}
onSelect={openConfiguration}
/>
)
}
return (
<>
<PublisherEnvironmentSummarySection
deployment={deployment}
environmentTabs={environmentTabs}
latestVersion={latestVersion}
onDeployLatest={() => {
if (latestVersion) openConfiguration(latestVersion)
}}
onDeployOtherVersion={openVersionSelection}
onGoToPublish={onGoToPublish}
onShowAllVersions={openVersionSelection}
/>
<PublisherEnvironmentActionsSection
appId={appId}
deployment={deployment}
environmentId={environmentId}
/>
</>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import type { ReactNode } from 'react'
import type { MockEnvironmentDeployment, MockVersion } from '@/app/components/app/deploy/mock-data'
import { Button } from '@langgenius/dify-ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import SuggestedAction from '@/app/components/app/app-publisher/suggested-action'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { PublisherTimelineMarker } from './sections'
type PublisherEnvironmentSummarySectionProps = {
deployment?: MockEnvironmentDeployment
environmentTabs: ReactNode
latestVersion?: MockVersion
onDeployLatest: () => void
onDeployOtherVersion: () => void
onGoToPublish: () => void
onShowAllVersions: () => void
}
type PublisherEnvironmentActionsSectionProps = {
appId?: string
deployment?: MockEnvironmentDeployment
environmentId: string
}
function environmentHref(path: string, appId: string, environmentId: string) {
return `/app/${appId}/${path}?environment=${encodeURIComponent(environmentId)}`
}
export function PublisherEnvironmentSummarySection({
deployment,
environmentTabs,
latestVersion,
onDeployLatest,
onDeployOtherVersion,
onGoToPublish,
onShowAllVersions,
}: PublisherEnvironmentSummarySectionProps) {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const deployedVersion = deployment?.version
const versionsBehindLabel =
deployedVersion?.behind === undefined
? undefined
: deployedVersion.behind === 1
? t(($) => $['studio.versionsBehind_one'], {
ns: 'deployments',
count: deployedVersion.behind,
})
: t(($) => $['studio.versionsBehind_other'], {
ns: 'deployments',
count: deployedVersion.behind,
})
const isLatestVersion = Boolean(deployedVersion?.latest)
if (!deployedVersion) {
return (
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
<div className="min-w-0 flex-1">
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['studio.accessPoint.noPublishedTitle'], {
ns: 'deployments',
})}
</p>
<p className="system-xs-regular text-text-tertiary">
{t(($) => $['studio.publisher.noPublishedDescription'], {
ns: 'deployments',
})}
</p>
</div>
</div>
<Button variant="primary" className="w-full" onClick={onGoToPublish}>
{t(($) => $['studio.accessPoint.goToPublish'], { ns: 'deployments' })}
</Button>
</div>
)
}
return (
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">
{deployedVersion.name}
</span>
{versionsBehindLabel && !isLatestVersion && (
<Tooltip>
<TooltipTrigger
render={
<span
role="status"
aria-label={versionsBehindLabel}
className="inline-flex h-4.5 shrink-0 items-center rounded-[5px] border border-util-colors-orange-orange-500 px-1 system-2xs-medium text-util-colors-orange-orange-600"
>
<span aria-hidden className="i-ri-arrow-up-line size-3" />
{deployedVersion.behind}
</span>
}
/>
<TooltipContent role="tooltip">{versionsBehindLabel}</TooltipContent>
</Tooltip>
)}
{isLatestVersion && (
<span className="inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-text-accent bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-accent">
{t(($) => $['overview.chip.latest'], { ns: 'deployments' })}
</span>
)}
</div>
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(deployedVersion.publishedAt),
author: deployedVersion.publishedBy,
})}
</p>
</div>
</div>
<div className="flex w-full flex-col gap-1">
<Button
type="button"
variant="primary"
className="w-full"
disabled={isLatestVersion || !latestVersion}
onClick={onDeployLatest}
>
{t(($) => $['studio.deployLatest'], { ns: 'deployments' })}
</Button>
{isLatestVersion && (
<Button
type="button"
variant="tertiary"
className="w-full gap-1"
onClick={onDeployOtherVersion}
>
{t(($) => $['studio.deployOtherVersion'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-line size-4" />
</Button>
)}
</div>
{!isLatestVersion && (
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
<PublisherTimelineMarker position="bottom" />
<p className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
<span className="capitalize">
{t(($) => $['overview.chip.latest'], { ns: 'deployments' })}
</span>
{latestVersion ? `: ${latestVersion.name}` : ''}
</p>
<button
type="button"
className="flex shrink-0 items-center gap-0.5 rounded system-xs-regular text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onShowAllVersions}
>
{t(($) => $['studio.allVersions'], { ns: 'deployments' })}
<span aria-hidden className="i-ri-arrow-right-s-line size-3.5" />
</button>
</div>
)}
</div>
)
}
export function PublisherEnvironmentActionsSection({
appId,
deployment,
environmentId,
}: PublisherEnvironmentActionsSectionProps) {
const { t } = useTranslation()
const actionsDisabled = !appId || !deployment?.version
const accessPointHref = appId ? environmentHref('access-point', appId, environmentId) : undefined
const deployHref = appId ? environmentHref('deploy', appId, environmentId) : undefined
return (
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
<SuggestedAction
disabled={actionsDisabled}
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
link={accessPointHref}
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
>
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</SuggestedAction>
<SuggestedAction
disabled={actionsDisabled}
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
link={deployHref}
icon={<span className="i-ri-instance-line size-4" />}
>
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
</SuggestedAction>
</div>
)
}

View File

@@ -0,0 +1,475 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export const BUILT_IN_ENVIRONMENT_ID = 'built-in'
const DEFAULT_TABS_WIDTH = 320
const ENVIRONMENT_TAB_MAX_WIDTH = 88
const ENVIRONMENT_TAB_HORIZONTAL_PADDING = 16
const ENVIRONMENT_TAB_LABEL_MAX_WIDTH =
ENVIRONMENT_TAB_MAX_WIDTH - ENVIRONMENT_TAB_HORIZONTAL_PADDING
const SELECTED_OVERFLOW_LABEL_MAX_WIDTH = ENVIRONMENT_TAB_LABEL_MAX_WIDTH - 16
const TAB_GAP = 4
export type PublisherEnvironment = {
id: string
name: string
}
type EnvironmentTabMeasurements = {
availableWidth: number
builtInWidth: number
environmentTextWidths: Record<string, number>
moreEnvironmentsWidth: number
moreWidth: number
}
type EnvironmentTabLayout = {
overflowEnvironmentIds: string[]
showMore: boolean
visibleEnvironmentIds: string[]
}
type GetEnvironmentTabLayoutParams = {
availableWidth: number
builtInWidth: number
environmentTabWidths: Record<string, number>
hasUndeployedEnvironments: boolean
joinedEnvironmentIds: readonly string[]
moreEnvironmentsWidth: number
moreWidth: number
}
type PublisherEnvironmentTabsProps = {
environments: readonly PublisherEnvironment[]
joinedEnvironmentIds: readonly string[]
selectedEnvironmentId: string
onAddEnvironment: (environmentId: string) => void
onSelectEnvironment: (environmentId: string) => void
}
function estimateTextWidth(value: string) {
return Array.from(value).reduce((width, character) => {
const codePoint = character.codePointAt(0) ?? 0
if (codePoint > 0x2e7f) return width + 13
if (character === ' ') return width + 4
return width + 6.5
}, 0)
}
function estimateTabWidth(value: string) {
return Math.min(
ENVIRONMENT_TAB_MAX_WIDTH,
estimateTextWidth(value) + ENVIRONMENT_TAB_HORIZONTAL_PADDING,
)
}
function rowWidth(widths: readonly number[]) {
if (widths.length === 0) return 0
return widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * TAB_GAP
}
function getEnvironmentTabLayout({
availableWidth,
builtInWidth,
environmentTabWidths,
hasUndeployedEnvironments,
joinedEnvironmentIds,
moreEnvironmentsWidth,
moreWidth,
}: GetEnvironmentTabLayoutParams): EnvironmentTabLayout {
const joinedWidths = joinedEnvironmentIds.map(
(environmentId) => environmentTabWidths[environmentId] ?? ENVIRONMENT_TAB_MAX_WIDTH,
)
const allTabsFit =
!hasUndeployedEnvironments && rowWidth([builtInWidth, ...joinedWidths]) <= availableWidth
if (allTabsFit) {
return {
overflowEnvironmentIds: [],
showMore: false,
visibleEnvironmentIds: [...joinedEnvironmentIds],
}
}
const showMore = hasUndeployedEnvironments || joinedEnvironmentIds.length > 0
if (!showMore) {
return {
overflowEnvironmentIds: [],
showMore: false,
visibleEnvironmentIds: [],
}
}
const triggerWidth =
joinedEnvironmentIds.length === 0
? moreEnvironmentsWidth
: Math.max(moreWidth, ENVIRONMENT_TAB_MAX_WIDTH)
const visibleEnvironmentIds: string[] = []
let visibleTabsWidth = builtInWidth
for (const environmentId of joinedEnvironmentIds) {
const environmentWidth = environmentTabWidths[environmentId] ?? ENVIRONMENT_TAB_MAX_WIDTH
const nextVisibleTabsWidth = visibleTabsWidth + TAB_GAP + environmentWidth
const widthWithTrigger = nextVisibleTabsWidth + TAB_GAP + triggerWidth
if (widthWithTrigger > availableWidth) break
visibleEnvironmentIds.push(environmentId)
visibleTabsWidth = nextVisibleTabsWidth
}
return {
overflowEnvironmentIds: joinedEnvironmentIds.slice(visibleEnvironmentIds.length),
showMore: true,
visibleEnvironmentIds,
}
}
function EnvironmentTab({
active,
name,
textWidth,
onClick,
}: {
active: boolean
name: string
textWidth: number
onClick: () => void
}) {
const truncated = textWidth > ENVIRONMENT_TAB_LABEL_MAX_WIDTH
return (
<Tooltip disabled={!truncated}>
<TooltipTrigger
render={
<button
type="button"
role="tab"
aria-selected={active}
className={cn(
'flex h-7 max-w-22 shrink-0 items-center justify-center rounded-lg px-2 py-1.5 text-center system-sm-medium text-text-tertiary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
active && 'bg-state-base-active system-sm-semibold text-text-primary',
)}
onClick={onClick}
>
<span className="min-w-0 truncate">{name}</span>
</button>
}
/>
<TooltipContent role="tooltip">{name}</TooltipContent>
</Tooltip>
)
}
function EnvironmentMenuItem({
environment,
onClick,
}: {
environment: PublisherEnvironment
onClick: () => void
}) {
return (
<DropdownMenuItem className="mx-0 flex gap-2 px-2 py-1.5" onClick={onClick}>
<span aria-hidden className="i-ri-instance-line size-4 shrink-0 text-text-tertiary" />
<span className="grow truncate system-md-regular text-text-secondary">
{environment.name}
</span>
</DropdownMenuItem>
)
}
export function PublisherEnvironmentTabs({
environments,
joinedEnvironmentIds,
selectedEnvironmentId,
onAddEnvironment,
onSelectEnvironment,
}: PublisherEnvironmentTabsProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const measurementRef = useRef<HTMLDivElement>(null)
const builtInLabel = t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' })
const moreLabel = t(($) => $['operation.more'], { ns: 'common' })
const moreEnvironmentsLabel = t(($) => $['studio.moreEnvironments'], {
ns: 'deployments',
})
const [measurements, setMeasurements] = useState<EnvironmentTabMeasurements>(() => ({
availableWidth: DEFAULT_TABS_WIDTH,
builtInWidth: estimateTabWidth(builtInLabel),
environmentTextWidths: Object.fromEntries(
environments.map((environment) => [environment.id, estimateTextWidth(environment.name)]),
),
moreEnvironmentsWidth:
estimateTextWidth(moreEnvironmentsLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16,
moreWidth: estimateTextWidth(moreLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16,
}))
useEffect(() => {
const container = containerRef.current
const measurementRoot = measurementRef.current
if (!container || !measurementRoot) return
const readElementWidth = (element: HTMLElement | null, fallback: number) => {
return element?.getBoundingClientRect().width || element?.scrollWidth || fallback
}
const readWidth = (selector: string, fallback: number) =>
readElementWidth(measurementRoot.querySelector<HTMLElement>(selector), fallback)
const updateMeasurements = () => {
const environmentMeasureElements = new Map(
Array.from(measurementRoot.querySelectorAll<HTMLElement>('[data-environment-measure]')).map(
(element) => [element.dataset.environmentMeasure, element],
),
)
const environmentTextWidths = Object.fromEntries(
environments.map((environment) => [
environment.id,
readElementWidth(
environmentMeasureElements.get(environment.id) ?? null,
estimateTextWidth(environment.name),
),
]),
)
const nextMeasurements = {
availableWidth:
container.getBoundingClientRect().width || container.clientWidth || DEFAULT_TABS_WIDTH,
builtInWidth: Math.min(
ENVIRONMENT_TAB_MAX_WIDTH,
readWidth(
'[data-built-in-measure]',
estimateTextWidth(builtInLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING,
),
),
environmentTextWidths,
moreEnvironmentsWidth: readWidth(
'[data-more-environments-measure]',
estimateTextWidth(moreEnvironmentsLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16,
),
moreWidth: readWidth(
'[data-more-measure]',
estimateTextWidth(moreLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16,
),
}
setMeasurements((current) => {
const environmentWidthsUnchanged = environments.every(
(environment) =>
current.environmentTextWidths[environment.id] ===
nextMeasurements.environmentTextWidths[environment.id],
)
if (
current.availableWidth === nextMeasurements.availableWidth &&
current.builtInWidth === nextMeasurements.builtInWidth &&
current.moreEnvironmentsWidth === nextMeasurements.moreEnvironmentsWidth &&
current.moreWidth === nextMeasurements.moreWidth &&
environmentWidthsUnchanged
) {
return current
}
return nextMeasurements
})
}
const animationFrame = requestAnimationFrame(updateMeasurements)
if (typeof ResizeObserver === 'undefined') return () => cancelAnimationFrame(animationFrame)
const observer = new ResizeObserver(updateMeasurements)
observer.observe(container)
observer.observe(measurementRoot)
return () => {
cancelAnimationFrame(animationFrame)
observer.disconnect()
}
}, [builtInLabel, environments, moreEnvironmentsLabel, moreLabel])
const environmentById = new Map(environments.map((environment) => [environment.id, environment]))
const joinedEnvironmentIdSet = new Set(joinedEnvironmentIds)
const orderedJoinedEnvironmentIds = environments
.filter((environment) => joinedEnvironmentIdSet.has(environment.id))
.map((environment) => environment.id)
const undeployedEnvironments = environments.filter(
(environment) => !joinedEnvironmentIdSet.has(environment.id),
)
const environmentTabWidths = Object.fromEntries(
environments.map((environment) => [
environment.id,
Math.min(
ENVIRONMENT_TAB_MAX_WIDTH,
(measurements.environmentTextWidths[environment.id] ??
estimateTextWidth(environment.name)) + ENVIRONMENT_TAB_HORIZONTAL_PADDING,
),
]),
)
const layout = getEnvironmentTabLayout({
availableWidth: measurements.availableWidth,
builtInWidth: measurements.builtInWidth,
environmentTabWidths,
hasUndeployedEnvironments: undeployedEnvironments.length > 0,
joinedEnvironmentIds: orderedJoinedEnvironmentIds,
moreEnvironmentsWidth: measurements.moreEnvironmentsWidth,
moreWidth: measurements.moreWidth,
})
const overflowEnvironmentIdSet = new Set(layout.overflowEnvironmentIds)
const selectedOverflowEnvironment = overflowEnvironmentIdSet.has(selectedEnvironmentId)
? environmentById.get(selectedEnvironmentId)
: undefined
const overflowEnvironments = layout.overflowEnvironmentIds
.filter((environmentId) => environmentId !== selectedEnvironmentId)
.map((environmentId) => environmentById.get(environmentId))
.filter((environment): environment is PublisherEnvironment => Boolean(environment))
const triggerLabel =
selectedOverflowEnvironment?.name ??
(layout.visibleEnvironmentIds.length > 0 ? moreLabel : moreEnvironmentsLabel)
const selectedOverflowTextWidth = selectedOverflowEnvironment
? (measurements.environmentTextWidths[selectedOverflowEnvironment.id] ??
estimateTextWidth(selectedOverflowEnvironment.name))
: 0
const selectedOverflowNameTruncated =
Boolean(selectedOverflowEnvironment) &&
selectedOverflowTextWidth > SELECTED_OVERFLOW_LABEL_MAX_WIDTH
const showMoreEnvironmentsLabel =
!selectedOverflowEnvironment && layout.visibleEnvironmentIds.length === 0
return (
<div ref={containerRef} className="relative flex w-full items-start gap-1 pb-1">
<div
role="tablist"
aria-label={t(($) => $['studio.environments'], { ns: 'deployments' })}
className="flex min-w-0 items-start gap-1"
>
<EnvironmentTab
active={selectedEnvironmentId === BUILT_IN_ENVIRONMENT_ID}
name={builtInLabel}
textWidth={estimateTextWidth(builtInLabel)}
onClick={() => onSelectEnvironment(BUILT_IN_ENVIRONMENT_ID)}
/>
{layout.visibleEnvironmentIds.map((environmentId) => {
const environment = environmentById.get(environmentId)
if (!environment) return null
return (
<EnvironmentTab
key={environment.id}
active={selectedEnvironmentId === environment.id}
name={environment.name}
textWidth={
measurements.environmentTextWidths[environment.id] ??
estimateTextWidth(environment.name)
}
onClick={() => onSelectEnvironment(environment.id)}
/>
)
})}
</div>
{layout.showMore && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<button
type="button"
className={cn(
'flex h-7 min-w-0 items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-sm-medium text-text-tertiary outline-hidden',
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
!showMoreEnvironmentsLabel && 'max-w-22 shrink-0',
selectedOverflowEnvironment &&
'bg-state-base-active system-sm-semibold text-text-primary',
)}
>
<Tooltip disabled={!selectedOverflowNameTruncated}>
<TooltipTrigger
render={<span className="min-w-0 truncate">{triggerLabel}</span>}
/>
<TooltipContent role="tooltip">
{selectedOverflowEnvironment?.name}
</TooltipContent>
</Tooltip>
<span aria-hidden className="i-ri-arrow-down-s-line size-3.5 shrink-0" />
</button>
}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-42 rounded-xl p-0"
>
{overflowEnvironments.length > 0 && (
<DropdownMenuGroup className="p-1">
{overflowEnvironments.map((environment) => (
<EnvironmentMenuItem
key={environment.id}
environment={environment}
onClick={() => onSelectEnvironment(environment.id)}
/>
))}
</DropdownMenuGroup>
)}
{overflowEnvironments.length > 0 && undeployedEnvironments.length > 0 && (
<DropdownMenuSeparator className="my-0 bg-divider-subtle" />
)}
{undeployedEnvironments.length > 0 && (
<DropdownMenuGroup className="p-1">
<DropdownMenuLabel className="px-2 py-1 system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['card.notDeployed'], { ns: 'deployments' })}
</DropdownMenuLabel>
{undeployedEnvironments.map((environment) => (
<EnvironmentMenuItem
key={environment.id}
environment={environment}
onClick={() => onAddEnvironment(environment.id)}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div
ref={measurementRef}
aria-hidden
className="pointer-events-none invisible absolute top-0 left-0 flex items-center gap-1 whitespace-nowrap"
>
<span data-built-in-measure className="inline-flex px-2 py-1.5 system-sm-medium">
{builtInLabel}
</span>
<span
data-more-measure
className="inline-flex items-center gap-0.5 px-2 py-1.5 system-sm-medium"
>
{moreLabel}
<span className="size-3.5" />
</span>
<span
data-more-environments-measure
className="inline-flex items-center gap-0.5 px-2 py-1.5 system-sm-medium"
>
{moreEnvironmentsLabel}
<span className="size-3.5" />
</span>
{environments.map((environment) => (
<span
key={environment.id}
data-environment-measure={environment.id}
className="system-sm-medium"
>
{environment.name}
</span>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export const APP_PUBLISH_DRAFT_CHANGED = 'APP_PUBLISH_DRAFT_CHANGED'

View File

@@ -2,8 +2,8 @@ import type {
AppPublisherProps,
AppPublisherPublishParams,
} from '@/app/components/app/app-publisher'
import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/use-configuration-utils'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import {
AlertDialog,
AlertDialogActions,
@@ -14,7 +14,6 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppPublisher } from '@/app/components/app/app-publisher'
@@ -23,18 +22,12 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (
params?: AppPublisherPublishParams,
features?: Features,
) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
publishedConfig: ConfigurationPublishConfig
resetAppConfig?: () => void
}
@@ -43,6 +36,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
const features = useFeatures((s) => s.features)
const featuresStore = useFeaturesStore()
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
const { resetAppConfig } = props
const {
more_like_this,
opening_statement,
@@ -54,10 +48,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
retriever_resource,
annotation_reply,
file_upload,
resetAppConfig,
} = props.publishedConfig.modelConfig
const handleConfirm = useCallback(() => {
const handleConfirm = () => {
resetAppConfig?.()
const { features, setFeatures } = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
@@ -90,9 +83,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3,
} as FileUpload
})
setFeatures(newFeatures)
setFeatures(newFeatures, { silent: true })
setRestoreConfirmOpen(false)
}, [featuresStore, props])
}
const handlePublish = useCallback(
(params?: AppPublisherPublishParams) => {

View File

@@ -12,18 +12,22 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop
import { toast } from '@langgenius/dify-ui/toast'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { use, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
MOCK_ENVIRONMENT_DEPLOYMENTS,
MOCK_PUBLISHED_VERSIONS,
MOCK_UNDEPLOYED_ENVIRONMENTS,
} from '@/app/components/app/deploy/mock-data'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections'
import {
buildWorkflowLaunchUrl,
createWorkflowLaunchInitialValues,
isWorkflowLaunchInputSupported,
} from '@/app/components/app/overview/app-card-utils'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button'
@@ -31,31 +35,48 @@ import { collaborationManager } from '@/app/components/workflow/collaboration/co
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { isCurrentWorkspaceEditorAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { appDetailQueryKeyPrefix } from '@/service/use-apps'
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import {
appWorkflowQueryOptions,
useAppWorkflow,
useInvalidateAppWorkflow,
useUpdateWorkflow,
} from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AccessControl from '../app-access-control'
import { PublisherEnvironmentFlow } from './environment-deployment-flow'
import { BUILT_IN_ENVIRONMENT_ID, PublisherEnvironmentTabs } from './environment-tabs'
import { APP_PUBLISH_HOTKEY } from './hotkeys'
import {
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from './sections'
import SuggestedAction from './suggested-action'
import {
getDisabledFunctionTooltip,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from './utils'
import VersionInfoModal from './version-info-modal'
// TODO: Replace the studio deployment fixtures after the app-scoped environment API is connected.
const PUBLISHER_ENVIRONMENTS = [
...MOCK_ENVIRONMENT_DEPLOYMENTS.map(({ id, name }) => ({ id, name })),
...MOCK_UNDEPLOYED_ENVIRONMENTS.map((name, index) => ({
id: `available-${index}`,
name,
})),
]
const INITIAL_JOINED_ENVIRONMENT_IDS = MOCK_ENVIRONMENT_DEPLOYMENTS.map(
(environment) => environment.id,
)
export type AppPublisherProps = {
disabled?: boolean
@@ -63,6 +84,10 @@ export type AppPublisherProps = {
publishedAt?: number
/** only needed in workflow / chatflow mode */
draftUpdatedAt?: number
/** Current persisted workflow draft hash, used to compare with the published workflow. */
draftHash?: string
/** Non-workflow editors should pass their local dirty state. */
hasUnpublishedChanges?: boolean
debugWithMultipleModel?: boolean
multipleModelConfigs?: ModelAndParameter[]
/** modelAndParameter is passed when debugWithMultipleModel is true */
@@ -94,6 +119,8 @@ export function AppPublisher({
publishDisabled = false,
publishedAt,
draftUpdatedAt,
draftHash,
hasUnpublishedChanges,
debugWithMultipleModel = false,
multipleModelConfigs = [],
onPublish,
@@ -112,32 +139,50 @@ export function AppPublisher({
}: AppPublisherProps) {
const { t } = useTranslation()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false)
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false)
const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('')
const [workflowLaunchValues, setWorkflowLaunchValues] = useState<
Record<string, WorkflowLaunchInputValue>
>({})
const [publishingToMarketplace, setPublishingToMarketplace] = useState(false)
const [editVersionInfoOpen, setEditVersionInfoOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(BUILT_IN_ENVIRONMENT_ID)
const [joinedEnvironmentIds, setJoinedEnvironmentIds] = useState<string[]>(() => [
...INITIAL_JOINED_ENVIRONMENT_IDS,
])
const workflowStore = use(WorkflowContext)
const appDetail = useAppStore((state) => state.appDetail)
const setAppDetail = useAppStore((state) => state.setAppDetail)
const canManageTools = useCanManageTools()
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(
appDetail?.mode || AppModeEnum.CHAT,
const appMode = appDetail?.mode
const isWorkflowApp = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const supportsMultiEnvironment =
appMode === AppModeEnum.WORKFLOW && systemFeatures.enable_app_deploy
const isChatApp =
appMode === AppModeEnum.CHAT ||
appMode === AppModeEnum.AGENT_CHAT ||
appMode === AppModeEnum.COMPLETION
const { data: publishedWorkflow, isSuccess: isPublishedWorkflowSuccess } = useAppWorkflow(
isWorkflowApp ? (appDetail?.id ?? '') : '',
)
const currentPublishedAt =
isWorkflowApp && isPublishedWorkflowSuccess
? publishedWorkflow?.created_at
? publishedWorkflow.created_at * 1000
: undefined
: publishedAt
const { mutate: updateWorkflow } = useUpdateWorkflow()
const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter(
(input) => input.hide === true,
)
@@ -166,7 +211,6 @@ export function AppPublisher({
appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
const invalidateAppWorkflow = useInvalidateAppWorkflow()
const openAsyncWindow = useAsyncWindowOpen()
const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects)
@@ -176,10 +220,10 @@ export function AppPublisher({
appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS &&
!userCanAccessApp?.result,
)
const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission
const disabledFunctionButton = !currentPublishedAt || missingStartNode || noAccessPermission
const disabledFunctionTooltip = getDisabledFunctionTooltip({
t,
publishedAt,
publishedAt: currentPublishedAt,
missingStartNode,
noAccessPermission,
})
@@ -187,7 +231,6 @@ export function AppPublisher({
async function handlePublish(params?: ModelAndParameter | PublishWorkflowParams) {
try {
await onPublish?.(params)
setPublished(true)
const appId = appDetail?.id
const socket = appId ? webSocketClient.getSocket(appId) : null
@@ -214,7 +257,6 @@ export function AppPublisher({
})
} catch (error) {
console.warn('[app-publisher] publish failed', error)
setPublished(false)
}
}
@@ -233,25 +275,6 @@ export function AppPublisher({
onToggle?.(nextOpen)
setOpen(nextOpen)
if (nextOpen) setPublished(false)
}
async function handleOpenInExplore() {
await openAsyncWindow(
async () => {
if (!appDetail?.id) throw new Error('App not found')
const { installed_apps } = await fetchInstalledAppList(appDetail.id)
if (installed_apps?.length > 0)
return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}`
throw new Error(t(($) => $.notPublishedYet, { ns: 'app' }))
},
{
onError: (err) => {
toast.error(`${err.message || err}`)
},
},
)
}
async function handleAccessControlUpdate() {
@@ -304,9 +327,58 @@ export function AppPublisher({
}
}
const hasPublishedVersion = Boolean(currentPublishedAt)
const workflowHasUnpublishedChanges =
!currentPublishedAt ||
!draftHash ||
!publishedWorkflow?.hash ||
draftHash !== publishedWorkflow.hash
const resolvedHasUnpublishedChanges =
hasUnpublishedChanges ?? (isWorkflowApp ? workflowHasUnpublishedChanges : !currentPublishedAt)
function handleOpenVersionInfo() {
if (!publishedWorkflow) return
handleOpenChange(false)
setEditVersionInfoOpen(true)
}
function handleAddEnvironment(environmentId: string) {
setJoinedEnvironmentIds((currentEnvironmentIds) => {
if (currentEnvironmentIds.includes(environmentId)) return currentEnvironmentIds
return [...currentEnvironmentIds, environmentId]
})
setSelectedEnvironmentId(environmentId)
}
function handleUpdateVersionInfo(params: { id?: string; title: string; releaseNotes: string }) {
if (!appDetail?.id || !params.id) return
updateWorkflow(
{
url: `/apps/${appDetail.id}/workflows/${params.id}`,
title: params.title,
releaseNotes: params.releaseNotes,
},
{
onSuccess: () => {
toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' }))
invalidateAppWorkflow(appDetail.id)
},
onError: () => {
toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' }))
},
onSettled: () => {
setEditVersionInfoOpen(false)
},
},
)
}
useHotkey(APP_PUBLISH_HOTKEY, (e) => {
if (debugWithMultipleModel) return
e.preventDefault()
if (publishDisabled || published) return
if (publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)) return
handlePublish()
})
@@ -317,11 +389,10 @@ export function AppPublisher({
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
const action = typeof update.data.action === 'string' ? update.data.action : undefined
if (action === 'published') {
invalidateAppWorkflow(appId)
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
void queryClient
.fetchQuery(appWorkflowQueryOptions(appId))
.then((publishedWorkflow) => {
if (publishedWorkflow?.created_at)
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
})
.catch((error) => {
console.warn('[app-publisher] refresh published workflow failed', error)
@@ -330,9 +401,8 @@ export function AppPublisher({
})
return unsubscribe
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
}, [appDetail?.id, queryClient, workflowStore])
const hasPublishedVersion = !!publishedAt
const workflowToolVisible =
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const workflowToolAvailableForUser = workflowToolAvailable && canManageTools
@@ -353,7 +423,7 @@ export function AppPublisher({
const workflowTool = useConfigureButton({
enabled: workflowToolVisible && canManageTools,
published: workflowToolPublished,
detailNeedUpdate: workflowToolPublished && published,
detailNeedUpdate: workflowToolPublished && !resolvedHasUnpublishedChanges,
workflowAppId: appDetail?.id ?? '',
icon: workflowToolIcon,
name: appDetail?.name ?? '',
@@ -377,6 +447,21 @@ export function AppPublisher({
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}
const selectedEnvironmentDeployment = MOCK_ENVIRONMENT_DEPLOYMENTS.find(
(deployment) => deployment.id === selectedEnvironmentId,
)
const latestPublishedVersion = MOCK_PUBLISHED_VERSIONS.find((version) => version.latest)
const environmentTabs = supportsMultiEnvironment ? (
<PublisherEnvironmentTabs
environments={PUBLISHER_ENVIRONMENTS}
joinedEnvironmentIds={joinedEnvironmentIds}
selectedEnvironmentId={selectedEnvironmentId}
onAddEnvironment={handleAddEnvironment}
onSelectEnvironment={setSelectedEnvironmentId}
/>
) : undefined
const showBuiltInPublisher =
!supportsMultiEnvironment || selectedEnvironmentId === BUILT_IN_ENVIRONMENT_ID
return (
<>
@@ -395,90 +480,76 @@ export function AppPublisher({
alignOffset={crossAxisOffset}
popupClassName="border-none bg-transparent shadow-none"
>
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
draftUpdatedAt={draftUpdatedAt}
formatTimeFromNow={formatTimeFromNow}
handlePublish={handlePublish}
handleRestore={handleRestore}
isChatApp={isChatApp}
multipleModelConfigs={multipleModelConfigs}
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
/>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(
systemFeatures.webapp_auth.enabled &&
(isGettingUserCanAccessApp || isGettingAppWhiteListSubjects),
)}
accessMode={appDetail?.access_mode}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleOpenChange(false)
}}
handleOpenInExplore={() => {
handleOpenChange(false)
handleOpenInExplore()
}}
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
missingStartNode={missingStartNode}
published={published}
publishedAt={publishedAt}
showBatchRunConfig={
hiddenLaunchVariables.length > 0 &&
(appDetail?.mode === AppModeEnum.WORKFLOW ||
appDetail?.mode === AppModeEnum.COMPLETION)
}
showRunConfig={hiddenLaunchVariables.length > 0}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailableForUser}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolOutdated={workflowTool.outdated}
workflowToolMessage={workflowToolMessage}
onConfigureWorkflowTool={openWorkflowToolDrawer}
/>
{systemFeatures.enable_creators_platform && (
<div className="border-t border-divider-subtle p-4">
<SuggestedAction
icon={<span className="i-ri-store-line size-4" />}
disabled={!publishedAt || publishingToMarketplace}
onClick={handlePublishToMarketplace}
>
{publishingToMarketplace
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
</SuggestedAction>
</div>
<div className="flex max-h-[calc(100dvh-32px)] w-98 flex-col overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
{showBuiltInPublisher ? (
<>
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
draftUpdatedAt={draftUpdatedAt}
environmentTabs={environmentTabs}
formatTimeFromNow={formatTimeFromNow}
handlePublish={handlePublish}
handleRestore={handleRestore}
hasUnpublishedChanges={resolvedHasUnpublishedChanges}
isChatApp={isChatApp}
isWorkflowApp={isWorkflowApp}
multipleModelConfigs={multipleModelConfigs}
onEditVersion={handleOpenVersionInfo}
publishDisabled={publishDisabled}
publishedAt={currentPublishedAt}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
versionInfo={publishedWorkflow}
/>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(
systemFeatures.webapp_auth.enabled &&
(isGettingUserCanAccessApp || isGettingAppWhiteListSubjects),
)}
accessMode={appDetail?.access_mode}
onClick={() => {
handleOpenChange(false)
setShowAppAccessControl(true)
}}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleOpenRunConfig={handleOpenWorkflowLaunchDialog}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
marketplaceActionDisabled={!currentPublishedAt}
publishedAt={currentPublishedAt}
publishingToMarketplace={publishingToMarketplace}
showDeployAction={supportsMultiEnvironment && isCurrentWorkspaceEditor}
showMarketplaceAction={systemFeatures.enable_creators_platform}
showRunConfig={hiddenLaunchVariables.length > 0}
toolPublished={workflowToolPublished}
workflowToolAvailable={workflowToolAvailableForUser}
workflowToolIsLoading={workflowTool.isLoading}
workflowToolMessage={workflowToolMessage}
workflowToolOutdated={workflowTool.outdated}
onConfigureWorkflowTool={openWorkflowToolDrawer}
onPublishToMarketplace={handlePublishToMarketplace}
/>
</>
) : (
<PublisherEnvironmentFlow
key={selectedEnvironmentId}
appId={appDetail?.id}
deployment={selectedEnvironmentDeployment}
environmentId={selectedEnvironmentId}
environmentTabs={environmentTabs}
latestVersion={latestPublishedVersion}
onGoToPublish={() => setSelectedEnvironmentId(BUILT_IN_ENVIRONMENT_ID)}
/>
)}
</div>
</PopoverContent>
<EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen}
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
hiddenInputs={hiddenLaunchVariables}
/>
{showAppAccessControl && (
<AccessControl
app={appDetail!}
@@ -499,6 +570,14 @@ export function AppPublisher({
onSubmit={handleWorkflowLaunchConfirm}
/>
</Popover>
{editVersionInfoOpen && (
<VersionInfoModal
isOpen={editVersionInfoOpen}
versionInfo={publishedWorkflow ?? undefined}
onClose={() => setEditVersionInfoOpen(false)}
onPublish={handleUpdateVersionInfo}
/>
)}
{workflowToolDrawerOpen && canManageTools && (
<WorkflowToolDrawer
isAdd={!workflowToolPublished}

View File

@@ -19,11 +19,13 @@ import { useProviderContext } from '@/context/provider-context'
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
type PublishWithMultipleModelProps = {
disabled?: boolean
multipleModelConfigs: ModelAndParameter[]
// textGenerationModelList?: Model[]
onSelect: (v: ModelAndParameter) => void
}
const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
disabled = false,
multipleModelConfigs,
// textGenerationModelList = [],
onSelect,
@@ -55,13 +57,13 @@ const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
}
})
const triggerDisabled = disabled || !validModelConfigs.length
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={!validModelConfigs.length}
render={
<Button variant="primary" disabled={!validModelConfigs.length} className="mt-3 w-full" />
}
disabled={triggerDisabled}
render={<Button variant="primary" disabled={triggerDisabled} className="w-full" />}
>
<>
{t(($) => $['operation.applyConfig'], { ns: 'appDebug' })}

View File

@@ -1,21 +1,22 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { PublishWorkflowParams } from '@/types/workflow'
import type { PublishWorkflowParams, VersionHistory } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { AppModeEnum } from '@/types/app'
import ActionTooltip from './action-tooltip'
import { APP_PUBLISH_HOTKEY } from './hotkeys'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils'
import WorkflowToolAction from './workflow-tool-action'
type SummarySectionProps = Pick<
AppPublisherProps,
@@ -29,9 +30,13 @@ type SummarySectionProps = Pick<
formatTimeFromNow: (value: number) => string
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
environmentTabs?: ReactNode
hasUnpublishedChanges?: boolean
isChatApp: boolean
published: boolean
isWorkflowApp?: boolean
onEditVersion?: () => void
upgradeHighlightStyle: CSSProperties
versionInfo?: VersionHistory | null
}
type AccessSectionProps = {
@@ -44,12 +49,7 @@ type AccessSectionProps = {
type ActionsSectionProps = Pick<
AppPublisherProps,
| 'hasHumanInputNode'
| 'hasTriggerNode'
| 'missingStartNode'
| 'toolPublished'
| 'publishedAt'
| 'workflowToolAvailable'
'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable'
> & {
appDetail:
| {
@@ -66,17 +66,17 @@ type ActionsSectionProps = Pick<
appURL: string
disabledFunctionButton: boolean
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handleOpenRunConfig?: (url: string) => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
published: boolean
showBatchRunConfig?: boolean
marketplaceActionDisabled?: boolean
publishingToMarketplace?: boolean
showDeployAction?: boolean
showMarketplaceAction?: boolean
showRunConfig?: boolean
workflowToolIsLoading: boolean
workflowToolOutdated: boolean
workflowToolMessage?: string
workflowToolOutdated?: boolean
onConfigureWorkflowTool: () => void
onPublishToMarketplace?: () => void
}
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
@@ -98,97 +98,229 @@ export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MA
)
}
export const PublisherTimelineMarker = ({ position }: { position: 'top' | 'bottom' }) => (
<span
className={cn(
'relative flex w-4 shrink-0 items-start p-1',
position === 'top' ? 'self-stretch' : 'h-4',
)}
>
<span
aria-hidden
className="relative z-1 size-2 rounded-full border-2 border-text-quaternary bg-components-panel-bg"
/>
<span
aria-hidden
className={cn(
'absolute left-1/2 w-0.5 -translate-x-1/2 bg-divider-subtle',
position === 'top' ? 'top-3.5 -bottom-3.5' : '-top-3.5 h-4',
)}
/>
</span>
)
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
environmentTabs,
formatTimeFromNow,
handlePublish,
handleRestore,
hasUnpublishedChanges,
isChatApp,
isWorkflowApp = false,
multipleModelConfigs = [],
onEditVersion,
publishDisabled = false,
published,
publishedAt,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
versionInfo,
}: SummarySectionProps) => {
const { t } = useTranslation()
const hasPublishedVersion = Boolean(publishedAt)
const resolvedHasUnpublishedChanges = hasUnpublishedChanges ?? !hasPublishedVersion
const publishedTimestamp =
publishedAt || (versionInfo?.created_at ? versionInfo.created_at * 1000 : undefined)
const publisherName = versionInfo?.created_by.name
const markedName = versionInfo?.marked_name
const markedComment = versionInfo?.marked_comment
const publishButtonDisabled =
publishDisabled || (hasPublishedVersion && !resolvedHasUnpublishedChanges)
const publishButtonLabel = !hasPublishedVersion
? t(($) => $['common.publish'], { ns: 'workflow' })
: resolvedHasUnpublishedChanges
? t(($) => $['common.publishUpdate'], { ns: 'workflow' })
: t(($) => $['common.published'], { ns: 'workflow' })
return (
<div className="p-4 pt-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
{publishedAt
? t(($) => $['common.latestPublished'], { ns: 'workflow' })
: t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })}
</div>
{publishedAt ? (
<div className="flex items-center justify-between">
<div className="flex items-center system-sm-medium text-text-secondary">
{t(($) => $['common.publishedAt'], { ns: 'workflow' })} {formatTimeFromNow(publishedAt)}
<div className="flex flex-col gap-3 p-4">
{environmentTabs}
<div className="flex items-start gap-1 px-1 py-0.5">
<PublisherTimelineMarker position="top" />
{!hasPublishedVersion ? (
<p className="min-w-0 flex-1 system-xs-regular text-text-tertiary">
{t(($) => $['common.notPublishedYet'], { ns: 'workflow' })}
</p>
) : isWorkflowApp ? (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-h-4 min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">
{markedName || versionInfo?.version}
</span>
<span aria-hidden className="system-xs-regular text-text-tertiary">
·
</span>
{markedName ? (
<button
type="button"
className="flex size-4 shrink-0 items-center justify-center rounded text-text-tertiary outline-hidden hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
aria-label={t(($) => $['versionHistory.editVersionInfo'], { ns: 'workflow' })}
disabled={!versionInfo || !onEditVersion}
onClick={onEditVersion}
>
<span aria-hidden className="i-ri-edit-line size-3.5" />
</button>
) : (
<button
type="button"
className="flex min-w-0 items-center gap-1 rounded text-text-accent outline-hidden hover:text-text-accent-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-wait"
disabled={!versionInfo || !onEditVersion}
onClick={onEditVersion}
>
<span aria-hidden className="i-ri-edit-line size-3.5 shrink-0" />
<span className="truncate system-xs-medium">
{t(($) => $['versionHistory.nameIt'], { ns: 'workflow' })}
</span>
</button>
)}
</div>
{markedComment && (
<>
<p className="line-clamp-3 system-xs-regular wrap-break-word text-text-tertiary">
{markedComment}
</p>
<span aria-hidden className="my-1 h-px w-4 bg-divider-regular" />
</>
)}
{publishedTimestamp && (
<p className="system-xs-regular text-text-tertiary">
{publisherName
? t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(publishedTimestamp),
author: publisherName,
})
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
</p>
)}
</div>
{isChatApp && (
) : (
<div className="flex min-w-0 flex-1 items-start justify-between gap-2">
<div className="flex min-w-0 flex-col">
<p className="truncate system-sm-semibold text-text-secondary">
{t(($) => $['common.latestPublished'], { ns: 'workflow' })}
</p>
{publishedTimestamp && (
<p className="truncate system-xs-regular text-text-tertiary">
{publisherName
? t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(publishedTimestamp),
author: publisherName,
})
: `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`}
</p>
)}
</div>
{isChatApp && (
<Button
variant="secondary"
size="small"
className="h-6 shrink-0 gap-1"
onClick={handleRestore}
disabled={!resolvedHasUnpublishedChanges}
>
<span aria-hidden className="i-ri-reset-left-line size-3.5" />
{t(($) => $['common.restore'], { ns: 'workflow' })}
</Button>
)}
</div>
)}
</div>
<div className="flex w-full flex-col">
{debugWithMultipleModel ? (
<PublishWithMultipleModel
disabled={publishDisabled}
multipleModelConfigs={multipleModelConfigs}
onSelect={(item) => handlePublish(item)}
/>
) : (
<>
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
variant="primary"
className="w-full"
onClick={() => handlePublish()}
disabled={publishButtonDisabled}
>
{t(($) => $['common.restore'], { ns: 'workflow' })}
{publishButtonDisabled ? (
publishButtonLabel
) : (
<span className="flex items-center gap-1">
<span>{publishButtonLabel}</span>
<KbdGroup aria-hidden>
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
</span>
)}
</Button>
)}
</div>
) : (
<div className="flex items-center system-sm-medium text-text-secondary">
{t(($) => $['common.autoSaved'], { ns: 'workflow' })} ·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel ? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={(item) => handlePublish(item)}
/>
) : (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{published ? (
t(($) => $['common.published'], { ns: 'workflow' })
) : (
<div className="flex gap-1">
<span>{t(($) => $['common.publishUpdate'], { ns: 'workflow' })}</span>
<KbdGroup>
{APP_PUBLISH_HOTKEY.split('+').map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm/5 font-semibold text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
</span>
<span className="block">
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
</span>
</p>
<p className="mt-1 text-xs/4 text-text-secondary">
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
</p>
<UpgradeBtn isShort className="mt-2.25 mb-3 h-8 w-23.25 self-start" />
</div>
)}
</Button>
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p className="text-sm/5 font-semibold text-transparent" style={upgradeHighlightStyle}>
<span className="block">
{t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })}
</span>
<span className="block">
{t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })}
</span>
</p>
<p className="mt-1 text-xs/4 text-text-secondary">
{t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })}
</p>
<UpgradeBtn isShort className="mt-2.25 mb-3 h-8 w-23.25 self-start" />
</div>
</>
)}
</div>
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-1">
<PublisherTimelineMarker position="bottom" />
<p className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{resolvedHasUnpublishedChanges ? (
<>
{t(($) => $['common.unpublishedChanges'], { ns: 'workflow' })}
{isWorkflowApp && Boolean(draftUpdatedAt) && (
<>
{' · '}
{t(($) => $['common.savedAt'], {
ns: 'workflow',
time: formatTimeFromNow(draftUpdatedAt!),
})}
</>
)}
</>
) : (
t(($) => $['common.noChanges'], { ns: 'workflow' })
)}
</>
)}
</p>
</div>
</div>
)
}
@@ -219,8 +351,9 @@ export const PublisherAccessSection = ({
{t(($) => $['publishApp.title'], { ns: 'app' })}
</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-0 bg-components-input-bg-normal py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-primary-50 hover:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
@@ -234,7 +367,7 @@ export const PublisherAccessSection = ({
<div className="flex size-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
</div>
</div>
</button>
{!isAppAccessSet && (
<p className="mt-1 system-xs-regular text-text-warning">
{t(($) => $['publishApp.notSetDesc'], { ns: 'app' })}
@@ -246,143 +379,114 @@ export const PublisherAccessSection = ({
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip) return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
disabledFunctionButton,
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handleOpenRunConfig,
hasHumanInputNode = false,
hasTriggerNode = false,
missingStartNode = false,
marketplaceActionDisabled = false,
publishedAt,
showBatchRunConfig = false,
publishingToMarketplace = false,
showDeployAction = false,
showMarketplaceAction = false,
showRunConfig = false,
toolPublished,
toolPublished = false,
workflowToolAvailable = true,
workflowToolIsLoading,
workflowToolOutdated,
workflowToolMessage,
workflowToolOutdated = false,
onConfigureWorkflowTool,
onPublishToMarketplace,
}: ActionsSectionProps) => {
const { t } = useTranslation()
if (hasTriggerNode) return null
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
const appId = appDetail?.id
const hasPublishedVersion = Boolean(publishedAt)
const showOpenWebApp = !hasTriggerNode
const showDeploy = Boolean(showDeployAction && appId)
const showWorkflowTool =
appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode
const navigationDisabled = !hasPublishedVersion || !appId
const workflowToolDisabled =
!hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading)
return (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line size-4" />}
actionButton={
showRunConfig
? {
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
icon: <span className="i-ri-settings-2-line size-4" />,
onClick: () => handleOpenRunConfig?.(appURL),
}
: undefined
}
>
{t(($) => $['common.runApp'], { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? (
<div className="flex flex-col border-t-[0.5px] border-t-divider-regular p-3">
{showOpenWebApp && (
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line size-4" />}
description={
disabledFunctionButton && disabledFunctionTooltip
? disabledFunctionTooltip
: t(($) => $['common.openWebAppDescription'], { ns: 'workflow' })
}
external
focusableWhenDisabled={Boolean(disabledFunctionTooltip)}
link={appURL}
icon={<span className="i-ri-planet-line size-4" />}
actionButton={
showBatchRunConfig
showRunConfig && handleOpenRunConfig
? {
ariaLabel: t(($) => $['operation.config'], { ns: 'common' }),
icon: <span className="i-ri-settings-2-line size-4" />,
onClick: () =>
handleOpenRunConfig?.(
`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`,
),
onClick: () => handleOpenRunConfig(appURL),
}
: undefined
}
>
{t(($) => $['common.batchRunApp'], { ns: 'workflow' })}
{t(($) => $['common.openWebApp'], { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
) : (
)}
<SuggestedAction
disabled={navigationDisabled}
description={t(($) => $['common.accessPointDescription'], { ns: 'workflow' })}
link={appId ? `/app/${appId}/access-point` : undefined}
icon={<span className="i-custom-vender-agent-v2-access-point size-4" />}
>
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</SuggestedAction>
{showDeploy && (
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser size-4" />}
disabled={navigationDisabled}
description={t(($) => $['common.deployDescription'], { ns: 'workflow' })}
link={`/app/${appId}/deploy`}
icon={<span className="i-ri-instance-line size-4" />}
>
{t(($) => $['common.embedIntoSite'], { ns: 'workflow' })}
{t(($) => $['appMenus.deploy'], { ns: 'common' })}
</SuggestedAction>
)}
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
{showMarketplaceAction && (
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt) handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line size-4" />}
disabled={marketplaceActionDisabled || publishingToMarketplace || !onPublishToMarketplace}
description={t(($) => $['common.publishToMarketplaceDescription'], {
ns: 'workflow',
})}
icon={<span className="i-ri-store-2-line size-4" />}
onClick={onPublishToMarketplace}
>
{t(($) => $['common.openInExplore'], { ns: 'workflow' })}
{publishingToMarketplace
? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' })
: t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
<ActionTooltip
disabled={!publishedAt || missingStartNode}
tooltip={
!publishedAt
? t(($) => $.notPublishedYet, { ns: 'app' })
: t(($) => $.noUserInputNode, { ns: 'app' })
}
>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line size-4" />}
>
{t(($) => $['common.accessAPIReference'], { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
isLoading={workflowToolIsLoading}
outdated={workflowToolOutdated}
onConfigure={onConfigureWorkflowTool}
disabledReason={workflowToolMessage}
/>
)}
{showWorkflowTool && (
<>
<div aria-hidden className="m-1 h-px bg-divider-subtle" />
<WorkflowToolAction
disabled={workflowToolDisabled}
isLoading={workflowToolIsLoading}
message={workflowToolMessage}
outdated={workflowToolOutdated}
published={toolPublished}
onConfigure={onConfigureWorkflowTool}
/>
</>
)}
</div>
)

View File

@@ -1,80 +1,166 @@
import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react'
import type { PropsWithChildren, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
import { useId } from 'react'
import Link from '@/next/link'
type SuggestedActionButton = {
ariaLabel: string
icon: React.ReactNode
icon: ReactNode
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void
}
type SuggestedActionProps = PropsWithChildren<
HTMLProps<HTMLAnchorElement> & {
icon?: React.ReactNode
link?: string
disabled?: boolean
actionButton?: SuggestedActionButton
}
>
type SuggestedActionProps = PropsWithChildren<{
icon?: ReactNode
link?: string
external?: boolean
disabled?: boolean
focusableWhenDisabled?: boolean
description?: ReactNode
endIcon?: ReactNode
actionButton?: SuggestedActionButton
className?: string
onClick?: () => void
}>
const SuggestedAction = ({
icon,
link,
disabled,
external = false,
disabled = false,
focusableWhenDisabled = false,
description,
endIcon,
actionButton,
children,
className,
onClick,
actionButton,
...props
}: SuggestedActionProps) => {
const handleClick = (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (disabled) {
event.preventDefault()
return
}
onClick?.(event)
}
const handleActionClick = (event: ReactMouseEvent<HTMLButtonElement>) => {
if (disabled) {
event.preventDefault()
return
}
actionButton?.onClick(event)
}
const mainAction = (
<a
href={disabled ? undefined : link}
target="_blank"
rel="noreferrer"
className={cn(
'flex min-w-0 items-center justify-start gap-2 px-2.5 py-2 text-text-secondary transition-colors',
actionButton
? 'flex-1 rounded-l-lg'
: 'rounded-lg bg-background-section-burn not-first:mt-1',
disabled
? 'cursor-not-allowed opacity-30 shadow-xs'
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
)}
onClick={handleClick}
{...props}
>
<div className="relative size-4 shrink-0">{icon}</div>
<div className="shrink grow basis-0 system-sm-medium">{children}</div>
<RiArrowRightUpLine className="size-3.5 shrink-0" />
</a>
const id = useId()
const labelId = `${id}-label`
const descriptionId = `${id}-description`
const interactiveClassName = cn(
'group flex h-10 min-w-0 items-center gap-2 border-0 bg-transparent p-1 text-left outline-hidden transition-colors',
actionButton ? 'flex-1 rounded-l-lg' : 'w-full rounded-lg',
disabled
? cn(
'cursor-not-allowed',
!actionButton && 'opacity-30',
focusableWhenDisabled && 'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)
: 'cursor-pointer hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
!actionButton && className,
)
const content = (
<>
<span
aria-hidden
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs transition-colors',
!disabled && 'group-hover:text-text-accent group-focus-visible:text-text-accent',
)}
>
{icon}
</span>
<span className="min-w-0 flex-1">
<span
id={labelId}
className={cn(
'block truncate system-sm-medium text-text-secondary transition-colors',
!disabled && 'group-hover:text-text-primary group-focus-visible:text-text-primary',
)}
>
{children}
</span>
{description && (
<span
id={descriptionId}
className={cn(
'hidden truncate system-xs-regular text-text-tertiary',
!disabled && 'group-hover:block group-focus-visible:block',
)}
>
{description}
</span>
)}
</span>
<span className="flex min-h-4 min-w-4 shrink-0 items-center justify-center text-text-quaternary">
{endIcon ?? <span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />}
</span>
</>
)
const accessibilityProps = {
'aria-labelledby': labelId,
'aria-describedby': description ? descriptionId : undefined,
}
let mainAction: ReactNode
if (disabled) {
mainAction = (
<button
type="button"
disabled={!focusableWhenDisabled}
aria-disabled={focusableWhenDisabled || undefined}
className={interactiveClassName}
onClick={
focusableWhenDisabled
? (event) => {
event.preventDefault()
event.stopPropagation()
}
: undefined
}
onKeyDown={
focusableWhenDisabled
? (event) => {
if (event.key === 'Enter' || event.key === ' ') event.preventDefault()
}
: undefined
}
{...accessibilityProps}
>
{content}
</button>
)
} else if (!link) {
mainAction = (
<button
type="button"
className={interactiveClassName}
onClick={onClick}
{...accessibilityProps}
>
{content}
</button>
)
} else if (external) {
mainAction = (
<a
href={link}
target="_blank"
rel="noreferrer"
className={interactiveClassName}
onClick={onClick}
{...accessibilityProps}
>
{content}
</a>
)
} else {
mainAction = (
<Link href={link} className={interactiveClassName} onClick={onClick} {...accessibilityProps}>
{content}
</Link>
)
}
if (!actionButton) return mainAction
return (
<div
className={cn(
'flex items-stretch rounded-lg bg-background-section-burn not-first:mt-1',
disabled ? 'opacity-30 shadow-xs' : '',
'flex w-full min-w-0 items-stretch rounded-lg',
disabled && 'opacity-30',
className,
)}
>
@@ -84,14 +170,16 @@ const SuggestedAction = ({
aria-label={actionButton.ariaLabel}
disabled={disabled}
className={cn(
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors',
'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary outline-hidden transition-colors',
disabled
? 'cursor-not-allowed'
: 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent',
: 'cursor-pointer hover:bg-state-base-hover hover:text-text-accent focus-visible:bg-state-base-hover focus-visible:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid',
)}
onClick={handleActionClick}
onClick={actionButton.onClick}
>
{actionButton.icon}
<span aria-hidden className="flex size-4 items-center justify-center">
{actionButton.icon}
</span>
</button>
</div>
)

View File

@@ -0,0 +1,9 @@
type WorkflowToolDisabledReasonProps = {
message: string
}
const WorkflowToolDisabledReason = ({ message }: WorkflowToolDisabledReasonProps) => {
return <p className="mt-1 px-2.5 pb-2 system-xs-regular text-text-tertiary opacity-30">{message}</p>
}
export default WorkflowToolDisabledReason

View File

@@ -0,0 +1,133 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import Link from '@/next/link'
import SuggestedAction from '../suggested-action'
import WorkflowToolDisabledReason from './disabled-reason'
import WorkflowToolLoadingStatus from './loading-status'
import WorkflowToolSetupStatus from './setup-status'
import WorkflowToolStateLabel from './state-label'
type WorkflowToolActionProps = {
disabled: boolean
isLoading: boolean
message?: string
outdated: boolean
published: boolean
onConfigure: () => void
}
const WorkflowToolAction = ({
disabled,
isLoading,
message,
outdated,
published,
onConfigure,
}: WorkflowToolActionProps) => {
const { t } = useTranslation()
const disabledReason = disabled ? message : undefined
const workflowToolLabel = t(($) => $['common.workflowAsTool'], { ns: 'workflow' })
if (!published || isLoading)
return (
<div
className={cn(
'w-full rounded-lg'
)}
>
<SuggestedAction
className="flex-1"
disabled={disabled}
description={t(($) => $['common.workflowAsToolDescription'], { ns: 'workflow' })}
endIcon={
isLoading ? (
<WorkflowToolLoadingStatus label={t(($) => $.loading, { ns: 'appApi' })} />
) : (
<WorkflowToolSetupStatus
label={t(($) => $['common.configureRequired'], { ns: 'workflow' })}
/>
)
}
icon={<span className="i-ri-hammer-line size-4" />}
onClick={onConfigure}
>
{workflowToolLabel}
</SuggestedAction>
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
</div>
)
const configureLabel = outdated
? t(($) => $['common.workflowAsToolReconfigure'], { ns: 'workflow' })
: t(($) => $['common.configure'], { ns: 'workflow' })
const manageInToolsLabel = t(($) => $['common.manageInTools'], { ns: 'workflow' })
const stateLabel = outdated
? t(($) => $['common.workflowAsToolUpdateNeeded'], { ns: 'workflow' })
: t(($) => $['common.workflowAsToolReady'], { ns: 'workflow' })
return (
<div
className={cn(
'flex w-full flex-col rounded-lg',
disabled && 'opacity-30',
)}
>
<div className="flex items-start gap-2 p-1">
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-panel-on-panel-item-bg text-text-secondary shadow-xs"
>
<span className="i-ri-hammer-line size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-h-7 items-center gap-2 pt-1 pr-1 pb-1">
<span className="min-w-0 flex-1 truncate system-sm-medium text-text-secondary">
{workflowToolLabel}
</span>
<WorkflowToolStateLabel label={stateLabel} outdated={outdated} />
</div>
{outdated && (
<p className="pr-4 pb-1 system-xs-regular text-text-warning">
{t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })}
</p>
)}
<div className="flex items-center gap-1 py-1">
<Button
variant="secondary"
size="small"
disabled={disabled}
className="gap-1 px-1.5"
onClick={onConfigure}
>
<span aria-hidden className="i-ri-equalizer-2-line size-3.5" />
{configureLabel}
</Button>
{disabled ? (
<button
type="button"
disabled
className="flex h-6 cursor-not-allowed items-center gap-1 rounded-md px-2 system-xs-medium text-components-button-tertiary-text-disabled"
>
{manageInToolsLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</button>
) : (
<Link
href={buildIntegrationPath('workflow-tool')}
className="flex h-6 items-center gap-1 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-tertiary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{manageInToolsLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</Link>
)}
</div>
</div>
</div>
{disabledReason && <WorkflowToolDisabledReason message={disabledReason} />}
</div>
)
}
export default WorkflowToolAction

View File

@@ -0,0 +1,15 @@
type WorkflowToolLoadingStatusProps = {
label: string
}
const WorkflowToolLoadingStatus = ({ label }: WorkflowToolLoadingStatusProps) => {
return (
<span
role="status"
aria-label={label}
className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none"
/>
)
}
export default WorkflowToolLoadingStatus

View File

@@ -0,0 +1,17 @@
type WorkflowToolSetupStatusProps = {
label: string
}
const WorkflowToolSetupStatus = ({ label }: WorkflowToolSetupStatusProps) => {
return (
<span
role="status"
aria-label={label}
className="rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary"
>
{label}
</span>
)
}
export default WorkflowToolSetupStatus

View File

@@ -0,0 +1,25 @@
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
type WorkflowToolStateLabelProps = {
label: string
outdated: boolean
}
const WorkflowToolStateLabel = ({ label, outdated }: WorkflowToolStateLabelProps) => {
return (
<span
role="status"
aria-label={label}
className={cn(
'flex shrink-0 items-center gap-1 system-xs-semibold-uppercase',
outdated ? 'text-util-colors-warning-warning-600' : 'text-util-colors-green-green-600',
)}
>
<StatusDot size="small" status={outdated ? 'warning' : 'success'} />
{label}
</span>
)
}
export default WorkflowToolStateLabel

View File

@@ -214,6 +214,11 @@ const createViewModel = (
publishedConfig: {
modelConfig: createContextValue().modelConfig,
completionParams: {},
promptMode: createContextValue().promptMode,
chatPromptConfig: createContextValue().chatPromptConfig,
completionPromptConfig: createContextValue().completionPromptConfig,
datasetConfigs: createContextValue().datasetConfigs,
externalDataToolsConfig: createContextValue().externalDataToolsConfig,
},
resetAppConfig: vi.fn(),
} as ComponentProps<typeof AppPublisher>,
@@ -248,6 +253,7 @@ const createViewModel = (
onCompletionParamsChange: vi.fn(),
onConfirmUseGPT4: vi.fn(),
onEnableMultipleModelDebug: vi.fn(),
onFeatureStoreChange: vi.fn(),
onFeaturesChange: vi.fn(),
onHideDebugPanel: vi.fn(),
onModelChange: vi.fn(),

View File

@@ -99,6 +99,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
onCompletionParamsChange,
onConfirmUseGPT4,
onEnableMultipleModelDebug,
onFeatureStoreChange,
onFeaturesChange,
onHideDebugPanel,
onModelChange,
@@ -128,7 +129,7 @@ const ConfigurationView: FC<ConfigurationViewModel> = ({
return (
<ConfigContext.Provider value={contextValue}>
<FeaturesProvider features={featuresData}>
<FeaturesProvider features={featuresData} onFeaturesChange={onFeatureStoreChange}>
<MittProvider>
<div className="flex h-full flex-col">
<div className="relative flex h-50 grow pt-14">

View File

@@ -1143,6 +1143,7 @@ describe('Debug', () => {
enabled: true,
}),
}),
{ silent: true },
)
})

View File

@@ -369,7 +369,7 @@ const Debug: FC<IDebug> = ({
enabled: supportedVision,
}
})
setFeatures(newFeatures)
setFeatures(newFeatures, { silent: true })
}
}, [debugWithMultipleModel, featuresStore, mode, multipleModelConfigs, textGenerationModelList])

View File

@@ -24,6 +24,7 @@ describe('useAdvancedPromptConfig', () => {
it('should update the advanced chat prompt and mark user changes', () => {
const handleUserChangedPrompt = vi.fn()
const handlePublishConfigChange = vi.fn()
const { result } = renderHook(() =>
useAdvancedPromptConfig({
appMode: AppModeEnum.CHAT,
@@ -36,6 +37,7 @@ describe('useAdvancedPromptConfig', () => {
completionParams: {},
setCompletionParams: vi.fn(),
setStop: vi.fn(),
onPublishConfigChange: handlePublishConfigChange,
}),
)
@@ -51,6 +53,7 @@ describe('useAdvancedPromptConfig', () => {
])
expect(result.current.hasSetBlockStatus.query).toBe(true)
expect(handleUserChangedPrompt).toHaveBeenCalledTimes(1)
expect(handlePublishConfigChange).toHaveBeenCalledTimes(1)
})
it('should derive simple prompt block status from the pre-prompt', () => {

View File

@@ -1,5 +1,6 @@
/* oxlint-disable typescript/no-explicit-any */
import type { VisionSettings } from '@/types/app'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { withSelectorKey } from '@/test/i18n-mock'
import {
AgentStrategy,
@@ -153,12 +154,20 @@ describe('useConfiguration utils', () => {
is_team_authorization: false,
},
] as any,
datasetConfigs: {
datasets: { datasets: [] },
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 4,
} as any,
deletedTools: [{ provider_id: 'tool-1', tool_name: 'search' }],
mode: AppModeEnum.AGENT_CHAT,
nextDataSets: [{ id: 'dataset-1' }] as any,
})
expect(publishedConfig.completionParams).toEqual({ temperature: 0.7 })
expect(publishedConfig.datasetConfigs.top_k).toBe(4)
expect(publishedConfig.promptMode).toBe('simple')
expect(publishedConfig.externalDataToolsConfig).toHaveLength(1)
expect(publishedConfig.modelConfig).toEqual(
expect.objectContaining({
dataSets: [{ id: 'dataset-1' }],
@@ -180,6 +189,76 @@ describe('useConfiguration utils', () => {
)
})
it('should normalize an empty chat prompt config for completion apps', () => {
const publishedConfig = buildPublishedConfig({
backendModelConfig: {
chat_prompt_config: {},
completion_prompt_config: {
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: '',
user_prefix: '',
},
},
dataset_configs: {
datasets: { datasets: [] },
},
external_data_tools: [],
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: ModelModeType.completion,
completion_params: {},
},
user_input_form: [],
} as any,
collectionList: [],
datasetConfigs: {
datasets: { datasets: [] },
retrieval_model: RETRIEVE_TYPE.multiWay,
} as any,
mode: AppModeEnum.COMPLETION,
nextDataSets: [],
})
expect(publishedConfig.chatPromptConfig).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
expect(publishedConfig.modelConfig.chat_prompt_config).toEqual(DEFAULT_CHAT_PROMPT_CONFIG)
})
it('should normalize an empty completion prompt config for chat apps', () => {
const publishedConfig = buildPublishedConfig({
backendModelConfig: {
chat_prompt_config: {
prompt: [{ role: 'system', text: 'chat' }],
},
completion_prompt_config: {},
dataset_configs: {
datasets: { datasets: [] },
},
external_data_tools: [],
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4o',
mode: ModelModeType.chat,
completion_params: {},
},
user_input_form: [],
} as any,
collectionList: [],
datasetConfigs: {
datasets: { datasets: [] },
retrieval_model: RETRIEVE_TYPE.multiWay,
} as any,
mode: AppModeEnum.CHAT,
nextDataSets: [],
})
expect(publishedConfig.completionPromptConfig).toEqual(DEFAULT_COMPLETION_PROMPT_CONFIG)
expect(publishedConfig.modelConfig.completion_prompt_config).toEqual(
DEFAULT_COMPLETION_PROMPT_CONFIG,
)
})
it('should build dataset configs with reranking defaults', () => {
const datasetConfigs = buildConfigurationDatasetConfigs({
backendModelConfig: {
@@ -665,7 +744,6 @@ describe('useConfiguration utils', () => {
const onPublish = createPublishHandler({
appId: 'app-1',
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
citationConfig: { enabled: true } as any,
completionParamsState: { temperature: 0.7 },
completionPromptConfig: {
prompt: { text: 'completion' },
@@ -680,13 +758,13 @@ describe('useConfiguration utils', () => {
datasetConfigs: {
datasets: { datasets: [] },
retrieval_model: RETRIEVE_TYPE.multiWay,
top_k: 7,
} as any,
externalDataToolsConfig: [],
externalDataToolsConfig: [{ enabled: true, variable: 'external' }] as any,
hasSetBlockStatus: {
history: true,
query: true,
},
introduction: 'hello',
isAdvancedMode: true,
isFunctionCall: true,
mode: AppModeEnum.CHAT,
@@ -711,36 +789,40 @@ describe('useConfiguration utils', () => {
workflow_file_upload_limit: 1,
},
} as any,
moreLikeThisConfig: { enabled: true },
promptEmpty: false,
promptMode: 'advanced' as any,
resolvedModelModeType: ModelModeType.chat,
setCanReturnToSimpleMode,
setPublishedConfig,
speechToTextConfig: { enabled: false } as any,
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
t,
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
})
const result = await onPublish(mockUpdateAppModelConfig, undefined, {
moreLikeThis: { enabled: true },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false, voice: '', language: '' },
file: {
enabled: false,
image: {
const result = await onPublish(
mockUpdateAppModelConfig,
{
model: 'published-model',
provider: 'published-provider',
parameters: { temperature: 0.2 },
},
{
moreLikeThis: { enabled: true },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: true },
speech2text: { enabled: false },
text2speech: { enabled: false, voice: '', language: '' },
file: {
enabled: false,
detail: 'low',
number_limits: 1,
transfer_methods: ['local_file'],
},
image: {
enabled: false,
detail: 'low',
number_limits: 1,
transfer_methods: ['local_file'],
},
} as any,
suggested: { enabled: false },
citation: { enabled: true },
} as any,
suggested: { enabled: false },
citation: { enabled: true },
} as any)
)
expect(result).toBe(true)
expect(mockUpdateAppModelConfig).toHaveBeenCalledWith(
@@ -753,7 +835,24 @@ describe('useConfiguration utils', () => {
url: '/apps/app-1/model-config',
}),
)
expect(setPublishedConfig).toHaveBeenCalledTimes(1)
expect(setPublishedConfig).toHaveBeenCalledWith(
expect.objectContaining({
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] },
completionParams: { temperature: 0.2 },
datasetConfigs: expect.objectContaining({ top_k: 7 }),
externalDataToolsConfig: [{ enabled: true, variable: 'external' }],
modelConfig: expect.objectContaining({
file_upload: expect.objectContaining({
image: expect.objectContaining({ detail: 'low' }),
}),
model_id: 'published-model',
opening_statement: '',
provider: 'published-provider',
sensitive_word_avoidance: { enabled: true },
}),
promptMode: 'advanced',
}),
)
expect(mockToastSuccess).toHaveBeenCalledWith('api.success')
expect(setCanReturnToSimpleMode).toHaveBeenCalledWith(false)
})
@@ -764,7 +863,6 @@ describe('useConfiguration utils', () => {
createPublishHandler({
appId: 'app-1',
chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any,
citationConfig: { enabled: false } as any,
completionParamsState: { temperature: 0.7 },
completionPromptConfig: {
prompt: { text: 'completion' },
@@ -782,7 +880,6 @@ describe('useConfiguration utils', () => {
history: true,
query: true,
},
introduction: 'hello',
isAdvancedMode: true,
isFunctionCall: false,
mode: AppModeEnum.CHAT,
@@ -801,16 +898,12 @@ describe('useConfiguration utils', () => {
workflow_file_upload_limit: 1,
},
} as any,
moreLikeThisConfig: { enabled: false },
promptEmpty: false,
promptMode: 'advanced' as any,
resolvedModelModeType: ModelModeType.completion,
setCanReturnToSimpleMode: vi.fn(),
setPublishedConfig: vi.fn(),
speechToTextConfig: { enabled: false } as any,
suggestedQuestionsAfterAnswerConfig: { enabled: false } as any,
t,
textToSpeechConfig: { enabled: false, voice: '', language: '' } as any,
...overrides,
})

View File

@@ -1,5 +1,6 @@
/* oxlint-disable typescript/no-explicit-any */
import { act, waitFor } from '@testing-library/react'
import { APP_PUBLISH_DRAFT_CHANGED } from '@/app/components/app/app-publisher/events'
import { updateAppModelConfig } from '@/service/apps'
import { renderHook } from '@/test/console/render'
import { AppModeEnum, ModelModeType } from '@/types/app'
@@ -20,7 +21,14 @@ const mockSetConversationHistoriesRole = vi.fn()
const mockSetChatPromptConfig = vi.fn()
const mockSetCompletionPromptConfig = vi.fn()
const mockSetCurrentAdvancedPrompt = vi.fn()
type MockEvent = {
type: string
instanceId?: string
}
let mockEventSubscription: ((event: MockEvent) => void) | undefined
const mockEventEmit = vi.fn((event: MockEvent) => mockEventSubscription?.(event))
type AdvancedPromptConfigOptions = {
onPublishConfigChange?: () => void
onUserChangedPrompt: () => void
setStop: (stop: string[]) => void
}
@@ -84,6 +92,17 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEventEmit,
useSubscription: (callback: (event: MockEvent) => void) => {
mockEventSubscription = callback
},
},
}),
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
@@ -194,6 +213,7 @@ vi.mock('@/utils/completion-params', () => ({
describe('useConfiguration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEventSubscription = undefined
latestAdvancedPromptConfigOptions = undefined
mockTempStopState = []
mockCurrentModelFeatures = ['vision']
@@ -303,6 +323,236 @@ describe('useConfiguration', () => {
)
})
it('should track configuration events and clear them after publishing', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
expect(mockEventEmit).toHaveBeenCalledWith({
type: APP_PUBLISH_DRAFT_CHANGED,
instanceId: 'app-1',
})
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
expect(result.current.appPublisherProps.publishedConfig.datasetConfigs.top_k).toBe(8)
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 10,
})
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
mockEventEmit.mockClear()
mockSetChatPromptConfig.mockClear()
mockSetCompletionPromptConfig.mockClear()
act(() => {
result.current.appPublisherProps.resetAppConfig?.()
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
expect(result.current.contextValue.datasetConfigs.top_k).toBe(8)
expect(mockSetChatPromptConfig).toHaveBeenCalledWith({
prompt: [{ role: 'system', text: 'hi' }],
})
expect(mockSetCompletionPromptConfig).toHaveBeenCalledWith({
prompt: { text: 'completion' },
conversation_histories_role: {
assistant_prefix: 'assistant',
user_prefix: 'user',
},
})
expect(mockEventEmit).not.toHaveBeenCalled()
})
it('should ignore debug formatting events and track feature-store changes', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
mockEventSubscription?.({ type: 'ORCHESTRATE_CHANGED' })
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
act(() => {
result.current.onFeatureStoreChange()
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
act(() => {
result.current.onFeatureStoreChange({ moreLikeThis: { enabled: true } })
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
})
it('should enable multiple-model mode without marking publish config dirty', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
mockEventEmit.mockClear()
act(() => {
result.current.onEnableMultipleModelDebug()
})
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
expect.arrayContaining([
expect.objectContaining({
model: 'gpt-4o',
provider: 'langgenius/openai/openai',
}),
]),
)
expect(mockEventEmit).not.toHaveBeenCalled()
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
})
it('should update multiple-model debug configs without marking publish config dirty', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
mockEventEmit.mockClear()
const modelConfigs = [
{
id: 'model-1',
model: 'gpt-4o',
provider: 'langgenius/openai/openai',
parameters: { temperature: 0.7 },
},
{
id: 'model-2',
model: 'gpt-4.1',
provider: 'langgenius/openai/openai',
parameters: {},
},
{
id: 'model-3',
model: '',
provider: '',
parameters: {},
},
]
act(() => {
result.current.onMultipleModelConfigsChange(true, modelConfigs)
})
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(true, modelConfigs)
expect(mockEventEmit).not.toHaveBeenCalled()
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
})
it('should keep multiple-model debug state when restoring published config', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.onEnableMultipleModelDebug()
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
mockEventEmit.mockClear()
mockHandleMultipleModelConfigsChange.mockClear()
act(() => {
result.current.appPublisherProps.resetAppConfig?.()
})
expect(mockHandleMultipleModelConfigsChange).not.toHaveBeenCalled()
expect(mockEventEmit).not.toHaveBeenCalled()
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
})
it('should silently sync the selected multiple-model config after publishing', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
act(() => {
result.current.contextValue.setDatasetConfigs({
...result.current.contextValue.datasetConfigs,
top_k: 8,
})
})
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(true)
mockEventEmit.mockClear()
await act(async () => {
await result.current.appPublisherProps.onPublish!(
{
id: 'model-2',
model: 'gpt-4.1',
provider: 'langgenius/openai/openai',
parameters: { temperature: 0.2 },
},
result.current.featuresData,
)
})
expect(result.current.contextValue.modelConfig.model_id).toBe('gpt-4.1')
expect(result.current.contextValue.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.contextValue.completionParams).toEqual({ temperature: 0.2 })
expect(result.current.appPublisherProps.publishedConfig.modelConfig.model_id).toBe('gpt-4.1')
expect(result.current.appPublisherProps.hasUnpublishedChanges).toBe(false)
expect(mockEventEmit).not.toHaveBeenCalled()
})
it('should refresh the latest published time after publishing a non-workflow app', async () => {
const { result } = renderHook(() => useConfiguration())
await waitFor(() => {
expect(result.current.showLoading).toBe(false)
})
const previousPublishedAt = result.current.appPublisherProps.publishedAt ?? 0
expect(previousPublishedAt).toBe(1710000000000)
await act(async () => {
await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData)
})
expect(result.current.appPublisherProps.publishedAt).toBeGreaterThan(previousPublishedAt)
})
it('should block publishing when app release permission is missing', async () => {
mockAppPermissionKeys = [AppACLPermission.ViewLayout]

View File

@@ -7,7 +7,7 @@ import type {
} from '@/models/debug'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useState } from 'react'
import { useCallback, useState } from 'react'
import {
checkHasContextBlock,
checkHasHistoryBlock,
@@ -30,6 +30,7 @@ type Param = {
completionParams: FormValue
setCompletionParams: (params: FormValue) => void
setStop: (stop: string[]) => void
onPublishConfigChange?: () => void
}
const useAdvancedPromptConfig = ({
@@ -43,13 +44,28 @@ const useAdvancedPromptConfig = ({
completionParams,
setCompletionParams,
setStop,
onPublishConfigChange,
}: Param) => {
const isAdvancedPrompt = promptMode === PromptMode.advanced
const [chatPromptConfig, setChatPromptConfig] = useState<ChatPromptConfig>(() =>
const [chatPromptConfig, doSetChatPromptConfig] = useState<ChatPromptConfig>(() =>
clone(DEFAULT_CHAT_PROMPT_CONFIG),
)
const [completionPromptConfig, setCompletionPromptConfig] = useState<CompletionPromptConfig>(() =>
clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
const [completionPromptConfig, doSetCompletionPromptConfig] = useState<CompletionPromptConfig>(
() => clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
)
const setChatPromptConfig = useCallback(
(config: ChatPromptConfig) => {
doSetChatPromptConfig(config)
onPublishConfigChange?.()
},
[onPublishConfigChange],
)
const setCompletionPromptConfig = useCallback(
(config: CompletionPromptConfig) => {
doSetCompletionPromptConfig(config)
onPublishConfigChange?.()
},
[onPublishConfigChange],
)
const currentAdvancedPrompt = (() => {

View File

@@ -56,6 +56,28 @@ type DeletedTool = {
tool_name: string
}
const normalizeChatPromptConfig = (
chatPromptConfig: BackendModelConfig['chat_prompt_config'],
): NonNullable<BackendModelConfig['chat_prompt_config']> =>
chatPromptConfig?.prompt?.length ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG)
const normalizeCompletionPromptConfig = (
completionPromptConfig: BackendModelConfig['completion_prompt_config'],
): NonNullable<BackendModelConfig['completion_prompt_config']> =>
completionPromptConfig?.prompt && completionPromptConfig.conversation_histories_role
? completionPromptConfig
: clone(DEFAULT_COMPLETION_PROMPT_CONFIG)
export type ConfigurationPublishConfig = {
modelConfig: ModelConfig
completionParams: FormValue
promptMode: PromptMode
chatPromptConfig: NonNullable<BackendModelConfig['chat_prompt_config']>
completionPromptConfig: NonNullable<BackendModelConfig['completion_prompt_config']>
datasetConfigs: DatasetConfigs
externalDataToolsConfig: NonNullable<BackendModelConfig['external_data_tools']>
}
const buildPublishedModelConfig = ({
backendModelConfig,
collectionList,
@@ -99,6 +121,11 @@ const buildPublishedModelConfig = ({
backendModelConfig.dataset_query_variable,
),
},
prompt_type: backendModelConfig.prompt_type,
chat_prompt_config: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
completion_prompt_config: normalizeCompletionPromptConfig(
backendModelConfig.completion_prompt_config,
),
more_like_this: backendModelConfig.more_like_this ?? { enabled: false },
opening_statement: backendModelConfig.opening_statement,
suggested_questions: backendModelConfig.suggested_questions ?? [],
@@ -158,16 +185,18 @@ const buildPublishedModelConfig = ({
export const buildPublishedConfig = ({
backendModelConfig,
collectionList,
datasetConfigs,
deletedTools,
mode,
nextDataSets,
}: {
backendModelConfig: BackendModelConfig
collectionList: Collection[]
datasetConfigs: DatasetConfigs
deletedTools?: DeletedTool[]
mode: AppModeEnum
nextDataSets: DataSet[]
}) => ({
}): ConfigurationPublishConfig => ({
modelConfig: buildPublishedModelConfig({
backendModelConfig,
collectionList,
@@ -176,6 +205,16 @@ export const buildPublishedConfig = ({
nextDataSets,
}),
completionParams: backendModelConfig.model.completion_params,
promptMode:
backendModelConfig.prompt_type === PromptMode.advanced
? PromptMode.advanced
: PromptMode.simple,
chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
completionPromptConfig: normalizeCompletionPromptConfig(
backendModelConfig.completion_prompt_config,
),
datasetConfigs,
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
})
export const buildConfigurationDatasetConfigs = ({
@@ -369,19 +408,22 @@ export const loadConfigurationState = async ({
nextDataSets = data
}
const datasetConfigs = buildConfigurationDatasetConfigs({
backendModelConfig,
currentRerankModel,
currentRerankProvider,
nextDataSets,
})
return {
annotationConfig: normalizeAnnotationConfig(backendModelConfig.annotation_reply),
backendModelConfig,
canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced,
collectionList,
completionPromptConfig:
backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG),
datasetConfigs: buildConfigurationDatasetConfigs({
backendModelConfig,
currentRerankModel,
currentRerankProvider,
nextDataSets,
}),
completionPromptConfig: normalizeCompletionPromptConfig(
backendModelConfig.completion_prompt_config,
),
datasetConfigs,
externalDataToolsConfig: backendModelConfig.external_data_tools ?? [],
mode: response.mode as AppModeEnum,
moreLikeThisConfig: backendModelConfig.more_like_this || { enabled: false },
@@ -390,6 +432,7 @@ export const loadConfigurationState = async ({
publishedConfig: buildPublishedConfig({
backendModelConfig,
collectionList,
datasetConfigs,
deletedTools: response.deleted_tools,
mode: response.mode as AppModeEnum,
nextDataSets,
@@ -407,11 +450,7 @@ export const loadConfigurationState = async ({
},
visionConfig: backendModelConfig.file_upload?.image,
citationConfig: backendModelConfig.retriever_resource || { enabled: false },
chatPromptConfig:
backendModelConfig.chat_prompt_config &&
backendModelConfig.chat_prompt_config.prompt?.length > 0
? backendModelConfig.chat_prompt_config
: clone(DEFAULT_CHAT_PROMPT_CONFIG),
chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config),
introduction: backendModelConfig.opening_statement,
moderationConfig: backendModelConfig.sensitive_word_avoidance,
}
@@ -530,7 +569,6 @@ export const createPublishHandler =
({
appId,
chatPromptConfig,
citationConfig,
completionParamsState,
completionPromptConfig,
contextVar,
@@ -539,25 +577,19 @@ export const createPublishHandler =
datasetConfigs,
externalDataToolsConfig,
hasSetBlockStatus,
introduction,
isAdvancedMode,
isFunctionCall,
mode,
modelConfig,
moreLikeThisConfig,
promptEmpty,
promptMode,
resolvedModelModeType,
setCanReturnToSimpleMode,
setPublishedConfig,
speechToTextConfig,
suggestedQuestionsAfterAnswerConfig,
t: rawTranslate,
textToSpeechConfig,
}: {
appId: string
chatPromptConfig: BackendModelConfig['chat_prompt_config']
citationConfig: ModelConfig['retriever_resource']
completionParamsState: FormValue
completionPromptConfig: BackendModelConfig['completion_prompt_config']
contextVar?: string
@@ -566,21 +598,16 @@ export const createPublishHandler =
datasetConfigs: DatasetConfigs
externalDataToolsConfig: BackendModelConfig['external_data_tools']
hasSetBlockStatus: { history: boolean; query: boolean }
introduction: string
isAdvancedMode: boolean
isFunctionCall: boolean
mode: AppModeEnum
modelConfig: ModelConfig
moreLikeThisConfig: ModelConfig['more_like_this']
promptEmpty: boolean
promptMode: BackendModelConfig['prompt_type']
resolvedModelModeType: ModelModeType
setCanReturnToSimpleMode: (value: boolean) => void
setPublishedConfig: (config: { modelConfig: ModelConfig; completionParams: FormValue }) => void
speechToTextConfig: ModelConfig['speech_to_text']
suggestedQuestionsAfterAnswerConfig: ModelConfig['suggested_questions_after_answer']
setPublishedConfig: (config: ConfigurationPublishConfig) => void
t: SelectorTranslate<'appDebug' | 'common'>
textToSpeechConfig: ModelConfig['text_to_speech']
}) =>
async (
updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise<unknown>,
@@ -643,18 +670,47 @@ export const createPublishHandler =
await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body })
const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.opening_statement = introduction
draft.more_like_this = moreLikeThisConfig
draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig
draft.speech_to_text = speechToTextConfig
draft.text_to_speech = textToSpeechConfig
draft.retriever_resource = citationConfig
draft.provider = body.model.provider
draft.model_id = body.model.name
draft.mode = body.model.mode
draft.configs.prompt_template = body.pre_prompt
draft.prompt_type = body.prompt_type
draft.chat_prompt_config = normalizeChatPromptConfig(body.chat_prompt_config)
draft.completion_prompt_config = normalizeCompletionPromptConfig(
body.completion_prompt_config,
)
draft.opening_statement = body.opening_statement
draft.more_like_this = body.more_like_this
draft.suggested_questions = body.suggested_questions ?? []
draft.suggested_questions_after_answer = body.suggested_questions_after_answer
draft.speech_to_text = body.speech_to_text
draft.text_to_speech = body.text_to_speech
draft.file_upload = body.file_upload ?? null
draft.retriever_resource = body.retriever_resource
draft.sensitive_word_avoidance = body.sensitive_word_avoidance
draft.external_data_tools = body.external_data_tools
draft.system_parameters = body.system_parameters
const publishedAgentConfig = body.agent_mode as ModelConfig['agentConfig']
draft.agentConfig = {
...draft.agentConfig,
...publishedAgentConfig,
max_iteration: publishedAgentConfig.max_iteration || draft.agentConfig.max_iteration,
}
draft.dataSets = dataSets
})
setPublishedConfig({
modelConfig: nextModelConfig,
completionParams: completionParamsState,
completionParams: body.model.completion_params,
promptMode:
body.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple,
chatPromptConfig: normalizeChatPromptConfig(body.chat_prompt_config),
completionPromptConfig: normalizeCompletionPromptConfig(body.completion_prompt_config),
datasetConfigs: {
...datasetConfigs,
datasets: body.dataset_configs.datasets,
},
externalDataToolsConfig: body.external_data_tools ?? [],
})
toast.success(t(($) => $['api.success'], { ns: 'common' }))
setCanReturnToSimpleMode(false)

View File

@@ -1,5 +1,6 @@
'use client'
import type { ComponentProps } from 'react'
import type { ConfigurationPublishConfig } from './use-configuration-utils'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
@@ -33,6 +34,7 @@ import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { APP_PUBLISH_DRAFT_CHANGED } from '@/app/components/app/app-publisher/events'
import {
useDebugWithSingleOrMultipleModel,
useFormattingChangedDispatcher,
@@ -60,6 +62,7 @@ import {
DEFAULT_COMPLETION_PROMPT_CONFIG,
} from '@/config'
import { userProfileIdAtom } from '@/context/account-state'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state'
@@ -80,11 +83,6 @@ import {
loadConfigurationState,
} from './use-configuration-utils'
type PublishConfig = {
modelConfig: ModelConfig
completionParams: FormValue
}
type DebugConfigurationValue = ComponentProps<typeof ConfigContext.Provider>['value']
export type ConfigurationViewModel = {
@@ -107,6 +105,7 @@ export type ConfigurationViewModel = {
onCompletionParamsChange: (params: FormValue) => void
onConfirmUseGPT4: () => void
onEnableMultipleModelDebug: () => void
onFeatureStoreChange: OnFeaturesChange
onFeaturesChange: OnFeaturesChange
onHideDebugPanel: () => void
onModelChange: ComponentProps<typeof ModelParameterModal>['setModel']
@@ -144,7 +143,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
const setDetailSidebarMode = useSetDetailSidebarMode()
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const serverLatestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const appACLCapabilities = useMemo(
() =>
getAppACLCapabilities(appDetail?.permission_keys, {
@@ -160,36 +159,117 @@ export const useConfiguration = (): ConfigurationViewModel => {
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
const appId = matched?.[1] || ''
const [publishedAtOverride, setPublishedAtOverride] = useState({
appId,
value: 0,
})
const latestPublishedAt =
publishedAtOverride.appId === appId
? Math.max(serverLatestPublishedAt || 0, publishedAtOverride.value)
: serverLatestPublishedAt
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
const [publishedConfig, setPublishedConfig] = useState<ConfigurationPublishConfig | null>(null)
const [unpublishedChangesState, setUnpublishedChangesState] = useState({
appId,
value: false,
})
const hasUnpublishedChanges =
unpublishedChangesState.appId === appId && unpublishedChangesState.value
const [conversationId, setConversationId] = useState<string | null>('')
const { eventEmitter } = useEventEmitterContextContext()
const publishChangeTrackingAppIdRef = useRef('')
const dispatchPublishDraftChanged = useCallback(() => {
if (publishChangeTrackingAppIdRef.current !== appId) return
eventEmitter?.emit({
type: APP_PUBLISH_DRAFT_CHANGED,
instanceId: appId,
})
}, [appId, eventEmitter])
eventEmitter?.useSubscription((event) => {
if (
typeof event !== 'string' &&
event.type === APP_PUBLISH_DRAFT_CHANGED &&
event.instanceId === appId
)
setUnpublishedChangesState({ appId, value: true })
})
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] =
useBoolean(false)
const [introduction, setIntroduction] = useState('')
const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([])
const [introduction, doSetIntroduction] = useState('')
const setIntroduction = useCallback(
(value: string) => {
doSetIntroduction(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [suggestedQuestions, doSetSuggestedQuestions] = useState<string[]>([])
const setSuggestedQuestions = useCallback(
(value: string[]) => {
doSetSuggestedQuestions(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [controlClearChatMessage, setControlClearChatMessage] = useState(0)
const [prevPromptConfig, setPrevPromptConfig] = useState<PromptConfig>({
prompt_template: '',
prompt_variables: [],
})
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig>({
const [moreLikeThisConfig, doSetMoreLikeThisConfig] = useState<MoreLikeThisConfig>({
enabled: false,
})
const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] =
const setMoreLikeThisConfig = useCallback(
(value: MoreLikeThisConfig) => {
doSetMoreLikeThisConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [suggestedQuestionsAfterAnswerConfig, doSetSuggestedQuestionsAfterAnswerConfig] =
useState<MoreLikeThisConfig>({ enabled: false })
const [speechToTextConfig, setSpeechToTextConfig] = useState<MoreLikeThisConfig>({
const setSuggestedQuestionsAfterAnswerConfig = useCallback(
(value: MoreLikeThisConfig) => {
doSetSuggestedQuestionsAfterAnswerConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [speechToTextConfig, doSetSpeechToTextConfig] = useState<MoreLikeThisConfig>({
enabled: false,
})
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig>({
const setSpeechToTextConfig = useCallback(
(value: MoreLikeThisConfig) => {
doSetSpeechToTextConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [textToSpeechConfig, doSetTextToSpeechConfig] = useState<TextToSpeechConfig>({
enabled: false,
voice: '',
language: '',
})
const [citationConfig, setCitationConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const setTextToSpeechConfig = useCallback(
(value: TextToSpeechConfig) => {
doSetTextToSpeechConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [citationConfig, doSetCitationConfig] = useState<MoreLikeThisConfig>({ enabled: false })
const setCitationConfig = useCallback(
(value: MoreLikeThisConfig) => {
doSetCitationConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [annotationConfig, doSetAnnotationConfig] = useState<AnnotationReplyConfig>({
id: '',
enabled: false,
@@ -208,8 +288,22 @@ export const useConfiguration = (): ConfigurationViewModel => {
[formattingChangedDispatcher],
)
const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({ enabled: false })
const [externalDataToolsConfig, setExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
const [moderationConfig, doSetModerationConfig] = useState<ModerationConfig>({ enabled: false })
const setModerationConfig = useCallback(
(value: ModerationConfig) => {
doSetModerationConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [externalDataToolsConfig, doSetExternalDataToolsConfig] = useState<ExternalDataTool[]>([])
const setExternalDataToolsConfig = useCallback(
(value: ExternalDataTool[]) => {
doSetExternalDataToolsConfig(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [inputs, setInputs] = useState<Inputs>({})
const [query, setQuery] = useState('')
const [completionParamsState, doSetCompletionParams] = useState<FormValue>({})
@@ -260,13 +354,18 @@ export const useConfiguration = (): ConfigurationViewModel => {
setTempStop([])
}
doSetCompletionParams(params)
dispatchPublishDraftChanged()
},
[getTempStop, setTempStop],
[dispatchPublishDraftChanged, getTempStop, setTempStop],
)
const setModelConfig = useCallback((newModelConfig: ModelConfig) => {
doSetModelConfig(newModelConfig)
}, [])
const setModelConfig = useCallback(
(newModelConfig: ModelConfig) => {
doSetModelConfig(newModelConfig)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const isAgent = mode === AppModeEnum.AGENT_CHAT
@@ -285,12 +384,23 @@ export const useConfiguration = (): ConfigurationViewModel => {
},
})
const datasetConfigsRef = useRef(datasetConfigs)
const setDatasetConfigs = useCallback((newDatasetConfigs: DatasetConfigs) => {
doSetDatasetConfigs(newDatasetConfigs)
datasetConfigsRef.current = newDatasetConfigs
}, [])
const setDatasetConfigs = useCallback(
(newDatasetConfigs: DatasetConfigs) => {
doSetDatasetConfigs(newDatasetConfigs)
datasetConfigsRef.current = newDatasetConfigs
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const [dataSets, setDataSets] = useState<DataSet[]>([])
const [dataSets, doSetDataSets] = useState<DataSet[]>([])
const setDataSets = useCallback(
(value: DataSet[]) => {
doSetDataSets(value)
dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key
const hasSetContextVar = !!contextVar
const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] =
@@ -304,30 +414,6 @@ export const useConfiguration = (): ConfigurationViewModel => {
const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } =
useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const syncToPublishedConfig = useCallback(
(_publishedConfig: PublishConfig) => {
const publishedModelConfig = _publishedConfig.modelConfig
setModelConfig(publishedModelConfig)
setCompletionParams(_publishedConfig.completionParams)
setDataSets(publishedModelConfig.dataSets || [])
setIntroduction(publishedModelConfig.opening_statement || '')
setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false })
setSuggestedQuestionsAfterAnswerConfig(
publishedModelConfig.suggested_questions_after_answer || { enabled: false },
)
setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false })
setTextToSpeechConfig(
publishedModelConfig.text_to_speech || {
enabled: false,
voice: '',
language: '',
},
)
setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false })
},
[setCompletionParams, setModelConfig],
)
const { isAPIKeySet } = useProviderContext()
const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({
provider: modelConfig.provider,
@@ -364,9 +450,10 @@ export const useConfiguration = (): ConfigurationViewModel => {
detail: config.detail || Resolution.low,
transfer_methods: config.transfer_methods || [TransferMethod.local_file],
})
dispatchPublishDraftChanged()
if (!notNoticeFormattingChanged) formattingChangedDispatcher()
},
[formattingChangedDispatcher],
[dispatchPublishDraftChanged, formattingChangedDispatcher],
)
const {
@@ -392,8 +479,77 @@ export const useConfiguration = (): ConfigurationViewModel => {
completionParams: completionParamsState,
setCompletionParams,
setStop: setTempStop,
onPublishConfigChange: dispatchPublishDraftChanged,
})
const syncToPublishedConfig = useCallback(
(_publishedConfig: ConfigurationPublishConfig) => {
const trackedAppId = publishChangeTrackingAppIdRef.current
publishChangeTrackingAppIdRef.current = ''
try {
const publishedModelConfig = _publishedConfig.modelConfig
setModelConfig(publishedModelConfig)
setCompletionParams(_publishedConfig.completionParams)
doSetPromptMode(_publishedConfig.promptMode)
setCanReturnToSimpleMode(_publishedConfig.promptMode !== PromptMode.advanced)
setChatPromptConfig(_publishedConfig.chatPromptConfig)
setCompletionPromptConfig(_publishedConfig.completionPromptConfig)
setDataSets(publishedModelConfig.dataSets || [])
setDatasetConfigs(_publishedConfig.datasetConfigs)
setExternalDataToolsConfig(_publishedConfig.externalDataToolsConfig)
setIntroduction(publishedModelConfig.opening_statement || '')
setSuggestedQuestions(publishedModelConfig.suggested_questions || [])
setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false })
setSuggestedQuestionsAfterAnswerConfig(
publishedModelConfig.suggested_questions_after_answer || { enabled: false },
)
setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false })
setTextToSpeechConfig(
publishedModelConfig.text_to_speech || {
enabled: false,
voice: '',
language: '',
},
)
setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false })
setModerationConfig(publishedModelConfig.sensitive_word_avoidance || { enabled: false })
const publishedVisionConfig = publishedModelConfig.file_upload?.image
handleSetVisionConfig(
{
enabled: publishedVisionConfig?.enabled || false,
number_limits: publishedVisionConfig?.number_limits || 2,
detail: publishedVisionConfig?.detail || Resolution.low,
transfer_methods: publishedVisionConfig?.transfer_methods || [
TransferMethod.local_file,
],
},
true,
)
} finally {
publishChangeTrackingAppIdRef.current = trackedAppId
}
},
[
handleSetVisionConfig,
setChatPromptConfig,
setCitationConfig,
setCompletionParams,
setCompletionPromptConfig,
setDataSets,
setDatasetConfigs,
setExternalDataToolsConfig,
setIntroduction,
setModelConfig,
setModerationConfig,
setMoreLikeThisConfig,
setSpeechToTextConfig,
setSuggestedQuestions,
setSuggestedQuestionsAfterAnswerConfig,
setTextToSpeechConfig,
],
)
const setPromptMode = useCallback(
async (nextMode: PromptMode) => {
if (nextMode === PromptMode.advanced) {
@@ -401,8 +557,9 @@ export const useConfiguration = (): ConfigurationViewModel => {
setCanReturnToSimpleMode(true)
}
doSetPromptMode(nextMode)
dispatchPublishDraftChanged()
},
[migrateToDefaultPrompt],
[dispatchPublishDraftChanged, migrateToDefaultPrompt],
)
const handleSelect = useCallback(
@@ -482,6 +639,12 @@ export const useConfiguration = (): ConfigurationViewModel => {
},
[formattingChangedDispatcher, setShowAppConfigureFeaturesModal],
)
const handleFeatureStoreChange = useCallback<OnFeaturesChange>(
(features) => {
if (features) dispatchPublishDraftChanged()
},
[dispatchPublishDraftChanged],
)
const handleAddPromptVariable = useCallback(
(variables: PromptVariable[]) => {
@@ -495,6 +658,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
)
useEffect(() => {
publishChangeTrackingAppIdRef.current = ''
void (async () => {
const configurationState = await loadConfigurationState({
appId,
@@ -505,35 +669,14 @@ export const useConfiguration = (): ConfigurationViewModel => {
setCollectionList(configurationState.collectionList)
setMode(configurationState.mode)
doSetPromptMode(configurationState.promptMode)
setDataSets(configurationState.nextDataSets)
setIntroduction(configurationState.introduction)
setSuggestedQuestions(configurationState.suggestedQuestions)
setMoreLikeThisConfig(configurationState.moreLikeThisConfig)
setSuggestedQuestionsAfterAnswerConfig(configurationState.suggestedQuestionsAfterAnswerConfig)
setSpeechToTextConfig(configurationState.speechToTextConfig)
setTextToSpeechConfig(configurationState.textToSpeechConfig)
setCitationConfig(configurationState.citationConfig)
setModerationConfig(configurationState.moderationConfig || { enabled: false })
setExternalDataToolsConfig(configurationState.externalDataToolsConfig)
setDatasetConfigs(configurationState.datasetConfigs)
if (configurationState.promptMode === PromptMode.advanced) {
setChatPromptConfig(configurationState.chatPromptConfig)
setCompletionPromptConfig(configurationState.completionPromptConfig as never)
setCanReturnToSimpleMode(false)
} else {
setCanReturnToSimpleMode(configurationState.canReturnToSimpleMode)
}
syncToPublishedConfig(configurationState.publishedConfig)
if (configurationState.annotationConfig)
setAnnotationConfig(configurationState.annotationConfig, true)
if (configurationState.visionConfig)
handleSetVisionConfig(configurationState.visionConfig, true)
syncToPublishedConfig(configurationState.publishedConfig as PublishConfig)
setPublishedConfig(configurationState.publishedConfig as PublishConfig)
setPublishedConfig(configurationState.publishedConfig)
setUnpublishedChangesState({ appId, value: false })
publishChangeTrackingAppIdRef.current = appId
setHasFetchedDetail(true)
})()
}, [appId])
@@ -572,11 +715,14 @@ export const useConfiguration = (): ConfigurationViewModel => {
params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined
const handlePublishedConfigChange = (config: ConfigurationPublishConfig) => {
setPublishedConfig(config)
if (modelAndParameter) syncToPublishedConfig(config)
}
return createPublishHandler({
const result = await createPublishHandler({
appId,
chatPromptConfig,
citationConfig,
completionParamsState,
completionPromptConfig,
contextVar,
@@ -585,28 +731,30 @@ export const useConfiguration = (): ConfigurationViewModel => {
datasetConfigs,
externalDataToolsConfig,
hasSetBlockStatus,
introduction,
isAdvancedMode,
isFunctionCall,
mode,
modelConfig,
moreLikeThisConfig,
promptEmpty,
promptMode,
resolvedModelModeType,
setCanReturnToSimpleMode,
setPublishedConfig,
speechToTextConfig,
suggestedQuestionsAfterAnswerConfig,
setPublishedConfig: handlePublishedConfigChange,
t,
textToSpeechConfig,
})(updateAppModelConfig, modelAndParameter, features)
if (result) {
setUnpublishedChangesState({ appId, value: false })
// The publish API currently returns only a result flag, so keep the summary current
// locally until app detail is refreshed with the server-side updated_at value.
setPublishedAtOverride({ appId, value: Math.floor(Date.now() / 1000) })
}
return result
},
[
appACLCapabilities.canReleaseAndVersion,
appId,
chatPromptConfig,
citationConfig,
completionParamsState,
completionPromptConfig,
contextVar,
@@ -615,21 +763,16 @@ export const useConfiguration = (): ConfigurationViewModel => {
datasetConfigs,
externalDataToolsConfig,
hasSetBlockStatus,
introduction,
isAdvancedMode,
isFunctionCall,
mode,
modelConfig,
moreLikeThisConfig,
promptEmpty,
promptMode,
resolvedModelModeType,
setCanReturnToSimpleMode,
setPublishedConfig,
speechToTextConfig,
suggestedQuestionsAfterAnswerConfig,
syncToPublishedConfig,
t,
textToSpeechConfig,
],
)
@@ -749,11 +892,16 @@ export const useConfiguration = (): ConfigurationViewModel => {
disabled: !appACLCapabilities.canReleaseAndVersion,
publishDisabled: cannotPublish || !appACLCapabilities.canReleaseAndVersion,
publishedAt: (latestPublishedAt || 0) * 1000,
hasUnpublishedChanges: !latestPublishedAt || hasUnpublishedChanges,
debugWithMultipleModel,
multipleModelConfigs,
onPublish,
publishedConfig: publishedConfig as PublishConfig,
resetAppConfig: () => publishedConfig && syncToPublishedConfig(publishedConfig),
publishedConfig: publishedConfig as ConfigurationPublishConfig,
resetAppConfig: () => {
if (!publishedConfig) return
syncToPublishedConfig(publishedConfig)
setUnpublishedChangesState({ appId, value: false })
},
},
contextValue,
featuresData,
@@ -776,6 +924,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
setShowUseGPT4Confirm(false)
},
onEnableMultipleModelDebug: handleDebugWithMultipleModelChange,
onFeatureStoreChange: handleFeatureStoreChange,
onFeaturesChange: handleFeaturesChange,
onHideDebugPanel: hideDebugPanel,
onModelChange: setModel,

View File

@@ -0,0 +1,356 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AppDeploy from '..'
import { EnvironmentTable } from '../environment-table'
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
return createReactI18nextMock({
'common.operation.cancel': 'Cancel',
'deployments.deployTab.confirmUndeploy': 'Undeploy',
'deployments.deployTab.undeployConfirmDesc':
'The environment endpoint will become unavailable, but the version will not be deleted.',
'deployments.deployTab.undeployConfirmTitle': 'Undeploy {{name}}?',
'deployments.deployTab.undeployConfirmWarning':
'After confirmation, this environment will enter the undeploying state and actions will be temporarily disabled.',
'workflow.common.publishedBy': 'Published {{time}} by {{author}}',
})
})
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => '17 days ago',
}),
}))
describe('AppDeploy', () => {
it('renders the built-in environment and mock deployment list', () => {
render(<AppDeploy />)
expect(
screen.getByRole('heading', { name: 'deployments.studio.builtInTitle' }),
).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: 'deployments.studio.environments' }),
).toBeInTheDocument()
expect(screen.getByRole('cell', { name: /Staging/ })).toBeInTheDocument()
expect(screen.getByRole('cell', { name: /Canary/ })).toBeInTheDocument()
expect(screen.getByRole('cell', { name: /Preview/ })).toBeInTheDocument()
expect(screen.getAllByRole('row')).toHaveLength(9)
})
it('opens the deploy menu with undeployed environments', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
expect(screen.getByText('deployments.card.notDeployed')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /Testing/ })).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /US-Prod/ })).toBeInTheDocument()
})
it('opens the selected environment version picker from the deploy menu', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
await user.click(screen.getByRole('menuitem', { name: /Dev/ }))
const dialog = await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
})
expect(within(dialog).getByText('deployments.studio.chooseVersionToDeploy')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: /#6/ })).toBeEnabled()
expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeDisabled()
})
it('continues from version selection to deployment configuration', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
await user.click(screen.getByRole('menuitem', { name: /Dev/ }))
const versionDialog = await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
})
await user.click(within(versionDialog).getByRole('button', { name: /#5/ }))
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
expect(within(configurationDialog).getByText('#5')).toBeInTheDocument()
expect(within(configurationDialog).getByText('Dev')).toBeInTheDocument()
expect(
within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }),
).toHaveTextContent('Enterprise key')
const portSource = within(configurationDialog).getByRole('combobox', { name: /PORT/ })
expect(portSource).toHaveTextContent('deployments.studio.configureValue')
expect(within(configurationDialog).getByRole('textbox', { name: 'PORT' })).toBeDisabled()
await user.click(portSource)
expect(
await screen.findByRole('option', {
name: 'deployments.deployDrawer.envVarSource.literal',
}),
).toBeInTheDocument()
expect(
screen.getByRole('option', { name: 'deployments.studio.configureValue' }),
).toBeInTheDocument()
expect(
screen.getByRole('option', {
name: 'deployments.deployDrawer.envVarSource.lastDeployment',
}),
).toBeInTheDocument()
await user.click(
screen.getByRole('option', {
name: 'deployments.deployDrawer.envVarSource.literal',
}),
)
const customPortInput = within(configurationDialog).getByRole('spinbutton', { name: 'PORT' })
expect(customPortInput).toBeEnabled()
await user.clear(customPortInput)
await user.type(customPortInput, '3000')
expect(customPortInput).toHaveValue(3000)
await user.click(
within(configurationDialog).getByRole('button', { name: 'common.operation.back' }),
)
expect(
await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
}),
).toBeInTheDocument()
})
it('opens the latest version configuration and allows choosing another version', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const preReleaseRow = screen.getByRole('row', { name: /Pre-release/ })
await user.click(
within(preReleaseRow).getByRole('button', {
name: 'deployments.studio.deployLatest',
}),
)
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
expect(within(configurationDialog).getByText('#6')).toBeInTheDocument()
expect(within(configurationDialog).getByText('Pre-release')).toBeInTheDocument()
await user.click(
within(configurationDialog).getByRole('button', { name: 'common.operation.back' }),
)
expect(
await screen.findByRole('dialog', {
name: 'deployments.studio.changeVersion · Pre-release',
}),
).toBeInTheDocument()
})
it('opens the failed version configuration for retry without a version-selection step', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const previewRow = screen.getByRole('row', { name: /Preview/ })
await user.click(
within(previewRow).getByRole('button', {
name: 'deployments.studio.retryVersion:{"version":"Sprint-42"}',
}),
)
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
expect(within(configurationDialog).getByText('Sprint-42')).toBeInTheDocument()
expect(within(configurationDialog).getByText('Preview')).toBeInTheDocument()
expect(
within(configurationDialog).queryByRole('button', { name: 'common.operation.back' }),
).not.toBeInTheDocument()
})
it('opens redeploy configuration without a version-selection step', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const qaRow = screen.getByRole('row', { name: /QA/ })
await user.click(
within(qaRow).getByRole('button', {
name: 'deployments.deployTab.redeploy',
}),
)
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
expect(within(configurationDialog).getByText('v0.3-beta')).toBeInTheDocument()
expect(within(configurationDialog).getByText('QA')).toBeInTheDocument()
expect(
within(configurationDialog).queryByRole('button', { name: 'common.operation.back' }),
).not.toBeInTheDocument()
})
it('opens change version for the selected deployed environment', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const canaryRow = screen.getByRole('row', { name: /Canary/ })
await user.click(
within(canaryRow).getByRole('button', {
name: 'deployments.studio.changeVersion',
}),
)
const dialog = await screen.findByRole('dialog', {
name: 'deployments.studio.changeVersion · Canary',
})
expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeDisabled()
expect(within(dialog).getByText('deployments.studio.current')).toBeInTheDocument()
})
it('shows the empty deployment state when there are no environments in use', async () => {
const user = userEvent.setup()
render(<EnvironmentTable deployments={[]} />)
expect(screen.getByText('deployments.list.emptyTitle')).toBeInTheDocument()
expect(screen.getByText('deployments.studio.emptyDescription')).toBeInTheDocument()
const deployButton = screen.getByRole('button', {
name: 'deployments.studio.deployToEnvironment',
})
await user.click(deployButton)
expect(screen.getByText('deployments.card.notDeployed')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /Testing/ })).toBeInTheDocument()
})
it('shows the environment actions from the design menu', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const canaryRow = screen.getByRole('row', { name: /Canary/ })
await user.click(
within(canaryRow).getByRole('button', {
name: 'Canary · deployments.deployTab.moreActions',
}),
)
const menu = await screen.findByRole('menu', {
name: 'Canary · deployments.deployTab.moreActions',
})
expect(
within(menu).getByRole('menuitem', { name: 'deployments.studio.changeVersion' }),
).toBeInTheDocument()
expect(
within(menu).getByRole('menuitem', { name: 'deployments.deployTab.redeploy' }),
).toBeInTheDocument()
expect(
within(menu).getByRole('menuitem', { name: 'deployments.deployTab.undeploy' }),
).toBeInTheDocument()
expect(
within(menu).queryByRole('menuitem', {
name: 'deployments.deployTab.deployOtherVersion',
}),
).not.toBeInTheDocument()
})
it('asks for confirmation before undeploying an environment', async () => {
const user = userEvent.setup()
const onUndeploy = vi.fn()
render(<EnvironmentTable onUndeploy={onUndeploy} />)
const canaryRow = screen.getByRole('row', { name: /Canary/ })
await user.click(
within(canaryRow).getByRole('button', {
name: 'Canary · deployments.deployTab.moreActions',
}),
)
await user.click(
within(screen.getByRole('menu')).getByRole('menuitem', {
name: 'deployments.deployTab.undeploy',
}),
)
const dialog = await screen.findByRole('alertdialog', { name: 'Undeploy Canary?' })
expect(
within(dialog).getByText(
'The environment endpoint will become unavailable, but the version will not be deleted.',
),
).toBeInTheDocument()
expect(
within(dialog).getByText(
'After confirmation, this environment will enter the undeploying state and actions will be temporarily disabled.',
),
).toBeInTheDocument()
expect(onUndeploy).not.toHaveBeenCalled()
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(onUndeploy).not.toHaveBeenCalled()
})
it('undeploys the selected environment after confirmation', async () => {
const user = userEvent.setup()
const onUndeploy = vi.fn()
render(<EnvironmentTable onUndeploy={onUndeploy} />)
const canaryRow = screen.getByRole('row', { name: /Canary/ })
await user.click(
within(canaryRow).getByRole('button', {
name: 'Canary · deployments.deployTab.moreActions',
}),
)
await user.click(
within(screen.getByRole('menu')).getByRole('menuitem', {
name: 'deployments.deployTab.undeploy',
}),
)
const dialog = await screen.findByRole('alertdialog', { name: 'Undeploy Canary?' })
await user.click(within(dialog).getByRole('button', { name: 'Undeploy' }))
expect(onUndeploy).toHaveBeenCalledOnce()
expect(onUndeploy).toHaveBeenCalledWith(expect.objectContaining({ id: 'canary' }))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
it('shows publication details and the version description on hover', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const canaryRow = screen.getByRole('row', { name: /Canary/ })
await user.hover(within(canaryRow).getByRole('button', { name: 'Sprint-42' }))
expect(await screen.findByText('Published 17 days ago by Minco')).toBeInTheDocument()
expect(
screen.getByText(
'Fixed several critical bugs affecting data synchronization and optimized page loading speed. Enhanced system stability and user experience through backend improvements.',
),
).toBeInTheDocument()
})
it('shows the compact version preview when there is no description', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const builtInSection = screen
.getByRole('heading', { name: 'deployments.studio.builtInTitle' })
.closest('section')
if (!builtInSection) throw new Error('Built-in environment section was not rendered')
await user.hover(within(builtInSection).getByRole('button', { name: 'Sprint-42' }))
expect(await screen.findByText('Published 17 days ago by Minco')).toBeInTheDocument()
expect(
screen.queryByText(
'Fixed several critical bugs affecting data synchronization and optimized page loading speed. Enhanced system stability and user experience through backend improvements.',
),
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,63 @@
'use client'
import type { AccessPoint } from './mock-data'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
const ACCESS_POINT_ICON_CLASS_NAMES: Record<AccessPoint, string> = {
mcp: 'i-custom-vender-integrations-mcp',
serviceApi: 'i-custom-vender-knowledge-api-aggregate',
trigger: 'i-custom-vender-integrations-trigger',
webApp: 'i-ri-robot-2-line',
}
function useAccessPointLabels() {
const { t: tAgent } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
return {
mcp: 'MCP',
serviceApi: tAgent(($) => $['agentDetail.access.serviceApi.title']),
trigger: tCommon(($) => $['settings.trigger']),
webApp: tAgent(($) => $['agentDetail.access.webApp.title']),
} satisfies Record<AccessPoint, string>
}
export function AccessPointIcon({
active,
accessPoint,
}: {
active: boolean
accessPoint: AccessPoint
}) {
const { t } = useTranslation('agentV2')
const labels = useAccessPointLabels()
const status = active
? t(($) => $['agentDetail.access.status.inService'])
: t(($) => $['agentDetail.access.status.outOfService'])
const label = `${labels[accessPoint]} · ${status}`
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={label}
className={cn(
'flex size-5 shrink-0 cursor-default items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
!active && 'opacity-30',
)}
>
<span
aria-hidden
className={cn(ACCESS_POINT_ICON_CLASS_NAMES[accessPoint], 'size-3')}
/>
</button>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,77 @@
'use client'
import { useTranslation } from 'react-i18next'
import { AccessPointIcon } from './access-point-icon'
import { DeploymentStatus } from './deployment-status'
import { ACCESS_POINT_ORDER, BUILT_IN_ENVIRONMENT } from './mock-data'
import { VersionLabel } from './version-label'
function Divider() {
return (
<div className="i-custom-vender-deploy-line-5 w-3 h-10" />
)
}
export function BuiltInEnvironmentCard() {
const { t } = useTranslation('deployments')
return (
<section
aria-labelledby="built-in-environment-title"
className="rounded-[14px] bg-background-section p-1 shrink-0"
>
<div className="flex flex-col gap-2.5 rounded-[10px] border-[0.5px] border-divider-subtle bg-components-panel-bg p-4">
{/* Icon */}
<div className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-regular">
<span aria-hidden className="i-ri-instance-line size-5 text-text-secondary" />
</div>
{/* Info */}
<div className="flex items-center gap-10">
<div className="flex flex-col gap-1">
<h2
id="built-in-environment-title"
className="system-md-semibold text-text-primary"
>
{t(($) => $['studio.builtInTitle'])}
</h2>
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.builtInDescription'])}
</p>
</div>
<Divider />
<div className="flex flex-col gap-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.liveVersion'])}
</div>
<VersionLabel version={BUILT_IN_ENVIRONMENT.version} />
</div>
<Divider />
<div className="flex flex-col gap-1">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.accessPoints'])}
</div>
<div className="flex items-center gap-1">
{ACCESS_POINT_ORDER.map((accessPoint) => (
<AccessPointIcon
key={accessPoint}
accessPoint={accessPoint}
active={BUILT_IN_ENVIRONMENT.accessPoints.includes(accessPoint)}
/>
))}
</div>
</div>
</div>
</div>
{/* Status and updated time */}
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<DeploymentStatus status="running" />
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.updatedAtBy'], {
name: BUILT_IN_ENVIRONMENT.actor,
time: BUILT_IN_ENVIRONMENT.updatedAt,
})}
</p>
</div>
</section>
)
}

View File

@@ -0,0 +1,67 @@
import type { MockDeploymentCredential } from '../mock-data'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import githubIcon from '../assets/github.png'
import moonshotIcon from '../assets/moonshot.png'
import slackIcon from '../assets/slack.png'
// todo: mock data for plugin icon, need to replace with real data when plugin icon is available
const CREDENTIAL_ICONS = {
github: githubIcon,
moonshot: moonshotIcon,
slack: slackIcon,
} satisfies Record<MockDeploymentCredential['id'], typeof githubIcon>
export function CredentialField({
credential,
value,
onChange,
}: {
credential: MockDeploymentCredential
value: string
onChange: (value: string) => void
}) {
const selectedOption = credential.options.find((option) => option.value === value)
const icon = CREDENTIAL_ICONS[credential.id]
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<span className="size-5 shrink-0 overflow-hidden rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
<img alt="" src={icon.src} className="size-full object-cover" />
</span>
<span className="system-sm-medium text-text-primary">{credential.name}</span>
<span className="system-xs-regular text-text-tertiary">{credential.category}</span>
</div>
<Select
value={value}
onValueChange={(nextValue) => {
if (nextValue) onChange(nextValue)
}}
>
<SelectTrigger aria-label={credential.name} className="px-2">
<span className="flex min-w-0 items-center gap-1.5">
<span className="flex size-4 shrink-0 items-center justify-center">
<span className="size-2 rounded-[3px] border border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg shadow-status-indicator-green-shadow" />
</span>
<span className="truncate">{selectedOption?.label}</span>
</span>
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{credential.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import type { MockVersion } from '../mock-data'
import type { DeploymentDialogRequest } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogCloseButton, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MOCK_DEPLOYMENT_CREDENTIALS, MOCK_ENVIRONMENT_VARIABLES } from '../mock-data'
import { CredentialField } from './credential-field'
import { EnvironmentVariableField } from './environment-variable-field'
function SectionHeading({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-w-0 flex-col">
<h3 className="system-md-semibold text-text-primary">{title}</h3>
<p className="system-xs-regular text-text-tertiary">{description}</p>
</div>
)
}
export function DeploymentConfigurationContent({
compact = false,
request,
version,
}: {
compact?: boolean
request: DeploymentDialogRequest
version: MockVersion
}) {
const { t } = useTranslation('deployments')
const [credentials, setCredentials] = useState<Record<string, string>>(() =>
Object.fromEntries(
MOCK_DEPLOYMENT_CREDENTIALS.map((credential) => [credential.id, credential.selectedValue]),
),
)
const [environmentVariables, setEnvironmentVariables] = useState(() =>
Object.fromEntries(
MOCK_ENVIRONMENT_VARIABLES.map((variable) => [
variable.key,
{
customValue: variable.customValue,
source: variable.source,
},
]),
),
)
const horizontalPaddingClassName = compact ? 'px-4' : 'px-6'
return (
<>
<div
className={cn(
'shrink-0 border-b border-divider-regular pt-2 pb-4',
horizontalPaddingClassName,
)}
>
<div className="flex items-center justify-between gap-3 rounded-xl bg-background-section p-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span aria-hidden className="i-ri-stack-line size-3.5 shrink-0 text-text-tertiary" />
<span className="truncate system-sm-medium text-text-secondary">{version.name}</span>
</div>
<span
aria-hidden
className="i-ri-arrow-right-line size-3.5 shrink-0 text-text-tertiary"
/>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<span aria-hidden className="i-ri-instance-line size-3.5 shrink-0 text-text-tertiary" />
<span className="truncate system-sm-medium text-text-secondary">
{request.environment}
</span>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<section className={cn('flex flex-col gap-4 py-4', horizontalPaddingClassName)}>
<SectionHeading
title={t(($) => $['deployDrawer.runtimeCredentials'])}
description={t(($) => $['deployDrawer.bindingSelectionHint'])}
/>
{MOCK_DEPLOYMENT_CREDENTIALS.map((credential) => (
<CredentialField
key={credential.id}
credential={credential}
value={credentials[credential.id] ?? credential.selectedValue}
onChange={(value) =>
setCredentials((current) => ({
...current,
[credential.id]: value,
}))
}
/>
))}
</section>
<section
className={cn(
'flex flex-col gap-4 border-t border-divider-regular py-4',
horizontalPaddingClassName,
)}
>
<SectionHeading
title={t(($) => $['deployDrawer.envVars'])}
description={t(($) => $['studio.environmentVariablesDescription'])}
/>
{MOCK_ENVIRONMENT_VARIABLES.map((variable) => {
const selection = environmentVariables[variable.key] ?? {
customValue: variable.customValue,
source: variable.source,
}
return (
<EnvironmentVariableField
key={variable.key}
variable={variable}
source={selection.source}
customValue={selection.customValue}
onSourceChange={(source) =>
setEnvironmentVariables((current) => ({
...current,
[variable.key]: {
...selection,
source,
},
}))
}
onCustomValueChange={(customValue) =>
setEnvironmentVariables((current) => ({
...current,
[variable.key]: {
...selection,
customValue,
},
}))
}
/>
)
})}
</section>
</div>
</>
)
}
export function DeploymentConfiguration({
request,
version,
onBack,
onClose,
}: {
request: DeploymentDialogRequest
version: MockVersion
onBack?: () => void
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
return (
<form
className="flex min-h-0 flex-1 flex-col"
onSubmit={(event) => {
event.preventDefault()
onClose()
}}
>
<DialogCloseButton
type="button"
aria-label={tCommon(($) => $['operation.close'])}
className="top-5 right-5 size-8 rounded-lg"
/>
<header className="shrink-0 px-5 pt-5 pr-14 pb-1">
{onBack && (
<Button
type="button"
size="small"
variant="ghost-accent"
className="-ml-1 h-6 gap-1 px-1 system-xs-semibold-uppercase"
onClick={onBack}
>
<span aria-hidden className="i-ri-arrow-left-line size-4" />
{tCommon(($) => $['operation.back'])}
</Button>
)}
<DialogTitle className="mt-0.5 px-1 title-2xl-semi-bold text-text-primary">
{t(($) => $['studio.deployConfiguration'])}
</DialogTitle>
<DialogDescription className="mt-1 px-1 system-xs-regular text-text-tertiary">
{t(($) => $['studio.deployConfigurationDescription'])}
</DialogDescription>
</header>
<DeploymentConfigurationContent request={request} version={version} />
<footer className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
<Button type="button" variant="secondary" onClick={onClose}>
{tCommon(($) => $['operation.cancel'])}
</Button>
<Button type="submit" variant="primary">
{tCommon(($) => $['appMenus.deploy'])}
</Button>
</footer>
</form>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import type { MockEnvironmentVariable, MockEnvironmentVariableValueSource } from '../mock-data'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
const ENVIRONMENT_VARIABLE_SOURCES: MockEnvironmentVariableValueSource[] = [
'custom',
'configured',
'lastDeployed',
]
export function EnvironmentVariableField({
variable,
source,
customValue,
onSourceChange,
onCustomValueChange,
}: {
variable: MockEnvironmentVariable
source: MockEnvironmentVariableValueSource
customValue: string
onSourceChange: (source: MockEnvironmentVariableValueSource) => void
onCustomValueChange: (value: string) => void
}) {
const { t } = useTranslation('deployments')
const sourceLabels: Record<MockEnvironmentVariableValueSource, string> = {
configured: t(($) => $['studio.configureValue']),
custom: t(($) => $['deployDrawer.envVarSource.literal']),
lastDeployed: t(($) => $['deployDrawer.envVarSource.lastDeployment']),
}
const valueTypeLabels: Record<MockEnvironmentVariable['valueType'], string> = {
number: t(($) => $['deployDrawer.envVarType.number']),
secret: t(($) => $['deployDrawer.envVarType.secret']),
string: t(($) => $['deployDrawer.envVarType.string']),
}
const inputId = `deployment-env-${variable.key}`
const editable = source === 'custom'
const value =
source === 'configured'
? variable.configuredValue
: source === 'lastDeployed'
? variable.lastDeployedValue
: customValue
const inputType =
editable && variable.valueType === 'secret'
? 'password'
: editable && variable.valueType === 'number'
? 'number'
: 'text'
return (
<div className="flex flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 grow items-center gap-1">
<span
aria-hidden
className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600"
/>
<label htmlFor={inputId} className="truncate system-sm-medium text-text-primary">
{variable.key}
</label>
<span className="shrink-0 system-xs-regular text-text-tertiary">
{valueTypeLabels[variable.valueType]}
</span>
{variable.valueType === 'secret' && (
<span aria-hidden className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" />
)}
</div>
<Select
value={source}
onValueChange={(nextSource) => {
if (nextSource) onSourceChange(nextSource)
}}
>
<SelectTrigger
aria-label={t(($) => $['deployDrawer.envVarSource.ariaLabel'], {
key: variable.key,
})}
size="small"
className="h-7 w-auto max-w-48 shrink-0 border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:bg-components-button-secondary-bg"
>
{sourceLabels[source]}
</SelectTrigger>
<SelectContent placement="bottom-end" popupClassName="w-52">
{ENVIRONMENT_VARIABLE_SOURCES.map((option) => (
<SelectItem key={option} value={option}>
<SelectItemText>{sourceLabels[option]}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
id={inputId}
type={inputType}
value={value}
disabled={!editable}
autoComplete="off"
onChange={(event) => onCustomValueChange(event.target.value)}
/>
{variable.description && (
<p className="system-xs-regular text-text-tertiary">{variable.description}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import type { MockVersion } from '../mock-data'
import type { DeploymentDialogRequest } from './types'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { useState } from 'react'
import { DeploymentConfiguration } from './deployment-configuration'
import { VersionSelection } from './version-selection'
type DeploymentDialogProps = {
request?: DeploymentDialogRequest
onClose: () => void
}
function DeploymentDialogSession({
request,
onClose,
}: {
request: DeploymentDialogRequest
onClose: () => void
}) {
const [selectedVersion, setSelectedVersion] = useState<MockVersion | undefined>(() =>
'initialVersion' in request ? request.initialVersion : undefined,
)
return (
<DialogContent className="flex max-h-[calc(100dvh-32px)] w-120 max-w-[calc(100vw-32px)] flex-col overflow-hidden p-0">
{selectedVersion ? (
<DeploymentConfiguration
key={selectedVersion.name}
request={request}
version={selectedVersion}
onBack={request.kind === 'redeploy' ? undefined : () => setSelectedVersion(undefined)}
onClose={onClose}
/>
) : (
<VersionSelection request={request} onSelect={setSelectedVersion} />
)}
</DialogContent>
)
}
export function DeploymentDialog({ request, onClose }: DeploymentDialogProps) {
return (
<Dialog open={Boolean(request)} onOpenChange={(open) => !open && onClose()}>
{request && (
<DeploymentDialogSession
key={`${request.kind}-${request.environment}-${request.currentVersion ?? 'none'}-${
'initialVersion' in request ? request.initialVersion.name : 'none'
}`}
request={request}
onClose={onClose}
/>
)}
</Dialog>
)
}

View File

@@ -0,0 +1,16 @@
import type { MockVersion } from '../mock-data'
type VersionSelectionRequest = {
currentVersion?: string
environment: string
kind: 'changeVersion' | 'deploy'
}
type ConfigurationRequest = {
currentVersion?: string
environment: string
initialVersion: MockVersion
kind: 'deployLatest' | 'redeploy'
}
export type DeploymentDialogRequest = ConfigurationRequest | VersionSelectionRequest

View File

@@ -0,0 +1,120 @@
'use client'
import type { ReactNode } from 'react'
import type { MockVersion } from '../mock-data'
import type { DeploymentDialogRequest } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { DialogCloseButton, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { MOCK_PUBLISHED_VERSIONS } from '../mock-data'
function VersionBadge({ children }: { children: ReactNode }) {
return (
<span className="inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-text-accent bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-accent">
{children}
</span>
)
}
function VersionTag({ children }: { children: ReactNode }) {
return (
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 system-2xs-medium text-text-tertiary">
{children}
</span>
)
}
export function VersionChoice({
version,
current,
onSelect,
}: {
version: MockVersion
current: boolean
onSelect: (version: MockVersion) => void
}) {
const { t } = useTranslation('deployments')
const { t: tWorkflow } = useTranslation('workflow')
const { formatTimeFromNow } = useFormatTimeFromNow()
return (
<button
type="button"
disabled={current}
onClick={() => onSelect(version)}
className="flex w-full cursor-pointer flex-col items-start gap-0.5 rounded-lg p-2 text-start outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<span className="flex min-w-0 items-center gap-1">
<span
className={cn('truncate system-md-medium text-text-secondary', current && 'opacity-50')}
>
{version.name}
</span>
{version.latest && <VersionBadge>{t(($) => $['overview.chip.latest'])}</VersionBadge>}
{current && <VersionBadge>{t(($) => $['studio.current'])}</VersionBadge>}
</span>
{version.description && (
<span className="line-clamp-3 system-xs-regular text-text-tertiary">
{version.description}
</span>
)}
<span className={cn('system-xs-regular text-text-tertiary', current && 'opacity-50')}>
{tWorkflow(($) => $['common.publishedBy'], {
time: formatTimeFromNow(version.publishedAt),
author: version.publishedBy,
})}
</span>
{version.tags && version.tags.length > 0 && (
<span className="flex flex-wrap items-center gap-1 pt-1">
{version.tags.map((tag) => (
<VersionTag key={tag}>{tag}</VersionTag>
))}
</span>
)}
</button>
)
}
export function VersionSelection({
request,
onSelect,
}: {
request: DeploymentDialogRequest
onSelect: (version: MockVersion) => void
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
const title =
request.kind === 'deploy'
? t(($) => $['versions.deployTo'], { name: request.environment })
: `${t(($) => $['studio.changeVersion'])} · ${request.environment}`
return (
<>
<DialogCloseButton
type="button"
aria-label={tCommon(($) => $['operation.close'])}
className="top-5 right-5 size-8 rounded-lg"
/>
<header className="shrink-0 px-6 pt-6 pr-14 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{title}</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['studio.chooseVersionToDeploy'])}
</DialogDescription>
</header>
<div className="min-h-0 overflow-y-auto px-4 pt-2 pb-4">
<div className="flex flex-col gap-px">
{MOCK_PUBLISHED_VERSIONS.map((version) => (
<VersionChoice
key={version.name}
version={version}
current={version.name === request.currentVersion}
onSelect={onSelect}
/>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import type { MockDeploymentStatus } from './mock-data'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
const STATUS_TEXT_CLASS_NAMES: Record<MockDeploymentStatus, string> = {
deploying: 'text-util-colors-blue-light-blue-light-600',
failed: 'text-util-colors-red-red-600',
running: 'text-util-colors-green-green-600',
}
export function DeploymentStatus({ status }: { status: MockDeploymentStatus }) {
const { t } = useTranslation('deployments')
const label =
status === 'deploying'
? t(($) => $['status.RUNTIME_INSTANCE_STATUS_DEPLOYING'])
: status === 'failed'
? t(($) => $['status.RUNTIME_INSTANCE_STATUS_FAILED'])
: t(($) => $['status.RUNTIME_INSTANCE_STATUS_READY'])
return (
<span
className={cn(
'inline-flex min-w-0 items-center gap-1 system-xs-semibold-uppercase',
STATUS_TEXT_CLASS_NAMES[status],
)}
>
{status === 'deploying' ? (
<span aria-hidden className="i-ri-loader-2-line size-4 shrink-0 animate-spin" />
) : (
<StatusDot size="small" status={status === 'failed' ? 'error' : 'success'} />
)}
<span className="truncate">{label}</span>
</span>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { MOCK_UNDEPLOYED_ENVIRONMENTS } from './mock-data'
type EnvironmentDeployMenuProps = {
appearance?: 'empty' | 'header'
onSelectEnvironment?: (environment: string) => void
}
export function EnvironmentDeployMenu({
appearance = 'header',
onSelectEnvironment,
}: EnvironmentDeployMenuProps) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
const isEmptyState = appearance === 'empty'
const label = isEmptyState
? t(($) => $['studio.deployToEnvironment'])
: tCommon(($) => $['appMenus.deploy'])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button variant={isEmptyState ? 'secondary' : 'primary'} className="gap-0 px-2">
<span aria-hidden className="i-ri-add-line size-4" />
<span className="pl-1.5">{label}</span>
<span aria-hidden className="i-ri-arrow-down-s-line size-4" />
</Button>
}
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-42 rounded-xl p-1"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1 system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['card.notDeployed'])}
</DropdownMenuLabel>
{MOCK_UNDEPLOYED_ENVIRONMENTS.map((environment) => (
<DropdownMenuItem
key={environment}
className="mx-0 flex gap-2 px-2 py-1.5"
onClick={() => onSelectEnvironment?.(environment)}
>
<span aria-hidden className="i-ri-instance-line size-4 shrink-0 text-text-tertiary" />
<span className="grow truncate system-md-regular text-text-secondary">
{environment}
</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,344 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { EnvironmentDeployMenu } from './environment-deploy-menu'
type SkeletonRow = {
accessPointCount: number
activity: string
actor: string
environment: string
environmentWidth?: number
id: string
status: string
version?: string
versionBadge?: string
}
const ACCESS_POINT_ICONS = [
'i-ri-robot-2-line',
'i-custom-vender-knowledge-api-aggregate',
'i-custom-vender-integrations-mcp',
'i-custom-vender-integrations-trigger',
]
const SKELETON_ROWS: SkeletonRow[] = [
{
accessPointCount: 3,
activity: 'Deploy Sprint-42 succeeded',
actor: 'by Evan · 3d ago',
environment: 'Canary',
id: 'canary-primary',
status: 'Disabled',
version: 'Sprint-42',
versionBadge: 'LATEST',
},
{
accessPointCount: 2,
activity: 'Deploy #11 succeeded',
actor: 'by Evan · 3d ago',
environment: 'Pre-release',
id: 'pre-release-primary',
status: 'Disabled',
version: 'Version-02',
versionBadge: '1',
},
{
accessPointCount: 2,
activity: 'Deploy #11 succeeded',
actor: 'by Rhonda · 3d ago',
environment: 'Prod',
id: 'prod',
status: 'Disabled',
version: 'v0.9-hotfix',
versionBadge: '1',
},
{
accessPointCount: 1,
activity: 'Deploy #10 failed',
actor: 'by Rhonda · 3d ago',
environment: 'EU-Prod',
id: 'eu-prod',
status: 'Disabled',
version: 'v0.6-beta',
versionBadge: '2',
},
{
accessPointCount: 3,
activity: 'Deploy Sprint-42 succeeded',
actor: 'by Evan · 3d ago',
environment: 'Canary',
id: 'canary-secondary',
status: 'Disabled',
version: 'Sprint-42',
versionBadge: 'LATEST',
},
{
accessPointCount: 1,
activity: 'Deploy #11 succeeded',
actor: 'by Rhonda · 3d ago',
environment: 'QA',
environmentWidth: 118,
id: 'qa',
status: 'Disabled',
version: 'v0.3-beta',
versionBadge: '1',
},
{
accessPointCount: 1,
activity: 'Deploy #11 succeeded',
actor: 'by Rhonda · 3d ago',
environment: 'Sandbox',
id: 'sandbox',
status: 'Disabled',
version: 'v0.3-beta',
versionBadge: '1',
},
{
accessPointCount: 1,
activity: 'Deploy #10 failed',
actor: 'by Rhonda · 3d ago',
environment: 'Preview',
id: 'preview',
status: 'Disabled',
},
{
accessPointCount: 2,
activity: 'Deploy #11 succeeded',
actor: 'by Evan · 3d ago',
environment: 'Pre-release',
id: 'pre-release-secondary',
status: 'Disabled',
version: 'Version-02',
versionBadge: '1',
},
]
const SKELETON_CLASS_NAME = 'bg-text-quaternary opacity-20'
const SKELETON_HEADER_LABELS = [
'Environment',
'Live version',
'Status',
'Last activity',
'Access points',
'Actions',
]
function HiddenSkeletonText({ children, className }: { children: string; className: string }) {
return (
<span className={cn(SKELETON_CLASS_NAME, 'inline-flex text-transparent', className)}>
{children}
</span>
)
}
function EmptyTableHeader() {
return (
<thead>
<tr className="h-7 bg-background-section-burn">
{SKELETON_HEADER_LABELS.map((label, index) => (
<th
key={label}
className={cn(
'pr-2 pl-3 text-left',
index === 0 && 'rounded-l-lg',
index === SKELETON_HEADER_LABELS.length - 1 && 'rounded-r-lg',
)}
>
<div className="flex h-7 items-center">
<HiddenSkeletonText className="h-2 rounded-xs system-xs-medium-uppercase">
{label}
</HiddenSkeletonText>
</div>
</th>
))}
</tr>
</thead>
)
}
function EnvironmentSkeleton({ name, width }: { name: string; width?: number }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular text-text-quaternary">
<span aria-hidden className="i-ri-instance-line size-3.5" />
</span>
{width ? (
<span className={cn(SKELETON_CLASS_NAME, 'h-3 shrink-0 rounded-[5px]')} style={{ width }} />
) : (
<HiddenSkeletonText className="h-3 rounded-[5px] system-md-medium">
{name}
</HiddenSkeletonText>
)}
</div>
)
}
function VersionSkeleton({ badge, version }: { badge?: string; version?: string }) {
if (!version) {
return (
<div className="flex items-center">
<span className={cn(SKELETON_CLASS_NAME, 'h-3 w-3 rounded-[5px]')} />
</div>
)
}
return (
<div className="flex min-w-0 items-center gap-1">
<HiddenSkeletonText className="h-3 rounded-[5px] system-sm-regular">
{version}
</HiddenSkeletonText>
{badge && (
<HiddenSkeletonText className="min-w-4 rounded-[5px] px-1 py-0.5 system-2xs-medium-uppercase">
{badge}
</HiddenSkeletonText>
)}
</div>
)
}
function StatusSkeleton({ label }: { label: string }) {
return (
<div className="flex items-center gap-1">
<span className="flex size-3 items-center justify-center">
<span className={cn(SKELETON_CLASS_NAME, 'size-2 rounded-[3px]')} />
</span>
<HiddenSkeletonText className="h-2 rounded-xs system-xs-semibold-uppercase">
{label}
</HiddenSkeletonText>
</div>
)
}
function ActivitySkeleton({ activity, actor }: { activity: string; actor: string }) {
return (
<div className="flex min-w-0 flex-col items-start gap-2">
<HiddenSkeletonText className="h-3 rounded-[5px] system-xs-regular">
{activity}
</HiddenSkeletonText>
<HiddenSkeletonText className="h-2 rounded-[5px] system-xs-regular">
{actor}
</HiddenSkeletonText>
</div>
)
}
function AccessPointSkeleton({ activeCount }: { activeCount: number }) {
return (
<div className="flex items-center gap-1">
{ACCESS_POINT_ICONS.map((icon, index) => (
<span
key={icon}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-quaternary',
index >= activeCount && 'opacity-30',
)}
>
<span aria-hidden className={cn(icon, 'size-3.5')} />
</span>
))}
</div>
)
}
function ActionsSkeleton() {
return (
<div className="flex items-center justify-end gap-1">
<span className="flex h-6 items-center gap-px rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-1.5 shadow-xs backdrop-blur-[5px]">
<span className="flex size-3.5 items-center justify-center">
<span className={cn(SKELETON_CLASS_NAME, 'size-2.5 rounded-[3px]')} />
</span>
<span className="flex px-0.75">
<span className={cn(SKELETON_CLASS_NAME, 'h-2 w-20 rounded-[5px]')} />
</span>
</span>
<span className="flex size-6 items-center justify-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[5px]">
<span className="flex size-3.5 items-center justify-center">
<span className={cn(SKELETON_CLASS_NAME, 'size-2.5 rounded-[3px]')} />
</span>
</span>
</div>
)
}
function EmptyTableRow({ row }: { row: SkeletonRow }) {
return (
<tr className="h-14">
<td className="border-b border-divider-subtle pr-2 pl-3">
<EnvironmentSkeleton name={row.environment} width={row.environmentWidth} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<VersionSkeleton badge={row.versionBadge} version={row.version} />
</td>
<td className="border-b border-divider-subtle px-2">
<StatusSkeleton label={row.status} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<ActivitySkeleton activity={row.activity} actor={row.actor} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<AccessPointSkeleton activeCount={row.accessPointCount} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<ActionsSkeleton />
</td>
</tr>
)
}
function EmptyTableSkeleton() {
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<table className="w-full min-w-260 table-fixed border-separate border-spacing-0 opacity-50">
<colgroup>
<col className="w-43" />
<col className="w-46" />
<col className="w-44" />
<col />
<col className="w-36" />
<col className="w-44" />
</colgroup>
<EmptyTableHeader />
<tbody>
{SKELETON_ROWS.map((row) => (
<EmptyTableRow key={row.id} row={row} />
))}
</tbody>
</table>
<div className="pointer-events-none absolute inset-0 z-10 bg-linear-to-b from-components-panel-bg-transparent to-components-panel-bg" />
</div>
)
}
export function EnvironmentTableEmpty({
onSelectEnvironment,
}: {
onSelectEnvironment?: (environment: string) => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="relative h-full w-full min-w-0 overflow-hidden">
<EmptyTableSkeleton />
<div className="absolute inset-0 z-20 flex items-center justify-center p-2">
<div className="flex w-full max-w-120 flex-col items-center justify-center gap-3 pt-6 pb-16">
<div className="flex size-14 items-center justify-center rounded-[10px]">
<div className="flex size-full items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1">
<span aria-hidden className="i-ri-instance-line size-6 text-text-tertiary" />
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<h3 className="system-md-medium text-text-secondary">
{t(($) => $['list.emptyTitle'])}
</h3>
<p className="system-sm-regular text-text-tertiary">
{t(($) => $['studio.emptyDescription'])}
</p>
</div>
<EnvironmentDeployMenu appearance="empty" onSelectEnvironment={onSelectEnvironment} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,363 @@
'use client'
import type { MockActivity, MockEnvironmentDeployment, MockRowAction } from './mock-data'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { AccessPointIcon } from './access-point-icon'
import { DeploymentStatus } from './deployment-status'
import { EnvironmentDeployMenu } from './environment-deploy-menu'
import { EnvironmentTableEmpty } from './environment-table-empty'
import {
ACCESS_POINT_ORDER,
MOCK_ENVIRONMENT_CAPACITY,
MOCK_ENVIRONMENT_DEPLOYMENTS,
} from './mock-data'
import { UndeployConfirmDialog } from './undeploy-confirm-dialog'
import { VersionLabel } from './version-label'
function activityLabel(
activity: MockActivity,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
) {
if (activity.result === 'failed')
return t(($) => $['studio.activity.deployFailed'], { target: activity.target })
if (activity.result === 'succeeded')
return t(($) => $['studio.activity.deploySucceeded'], { target: activity.target })
return t(($) => $['studio.activity.deploy'], { target: activity.target })
}
function ActivityCell({ activity }: { activity: MockActivity }) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const failed = activity.result === 'failed'
return (
<div className="flex min-w-0 flex-col gap-0.5">
<div
className={cn(
'flex min-w-0 items-center gap-1 truncate system-xs-medium',
failed ? 'text-text-warning' : 'text-text-secondary',
)}
>
{failed && (
<span aria-hidden className="i-ri-error-warning-fill size-3 shrink-0 text-text-warning" />
)}
<span className="truncate">{activityLabel(activity, t)}</span>
</div>
<div className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.activity.meta'], {
name: activity.actor,
time: formatTimeFromNow(activity.occurredAt),
})}
</div>
</div>
)
}
function rowActionLabel(
action: MockRowAction,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
) {
switch (action.kind) {
case 'changeVersion':
return t(($) => $['studio.changeVersion'])
case 'deployLatest':
return t(($) => $['studio.deployLatest'])
case 'redeploy':
return t(($) => $['deployTab.redeploy'])
case 'retry':
return t(($) => $['studio.retryVersion'], { version: action.version })
}
}
const ROW_ACTION_ICON_CLASS_NAMES: Record<MockRowAction['kind'], string> = {
changeVersion: 'i-ri-repeat-line',
deployLatest: 'i-custom-vender-deploy-rocket',
redeploy: 'i-ri-reset-left-line',
retry: 'i-ri-reset-left-line',
}
function RowActions({
row,
onChangeVersion,
onDeployLatest,
onRedeploy,
onUndeploy,
}: {
row: MockEnvironmentDeployment
onChangeVersion?: (deployment: MockEnvironmentDeployment) => void
onDeployLatest?: (deployment: MockEnvironmentDeployment) => void
onRedeploy?: (deployment: MockEnvironmentDeployment) => void
onUndeploy?: (deployment: MockEnvironmentDeployment) => void
}) {
const { t } = useTranslation('deployments')
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const label = rowActionLabel(row.action, t)
const canRedeploy = Boolean(row.version || row.action.kind === 'retry')
const handlePrimaryAction = useCallback(() => {
switch (row.action.kind) {
case 'changeVersion':
onChangeVersion?.(row)
break
case 'deployLatest':
onDeployLatest?.(row)
break
case 'redeploy':
case 'retry':
onRedeploy?.(row)
break
}
}, [onChangeVersion, onDeployLatest, onRedeploy, row])
const handleUndeploy = useCallback(() => {
onUndeploy?.(row)
setShowUndeployConfirm(false)
}, [onUndeploy, row])
return (
<>
<div className="flex items-center justify-end gap-1">
<Button
size="small"
variant="secondary"
disabled={row.action.kind === 'changeVersion' && row.action.disabled}
onClick={handlePrimaryAction}
className="min-w-0 gap-1 px-2"
>
<span
aria-hidden
className={cn(ROW_ACTION_ICON_CLASS_NAMES[row.action.kind], 'size-3.5 shrink-0')}
/>
<span className="truncate">{label}</span>
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={
<Button
size="small"
variant="secondary"
aria-label={`${row.name} · ${t(($) => $['deployTab.moreActions'])}`}
className="w-6 shrink-0 px-0"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-50">
<DropdownMenuItem
disabled={row.action.kind === 'changeVersion' && row.action.disabled}
className="gap-2 px-2"
onClick={() => onChangeVersion?.(row)}
>
<span aria-hidden className="i-ri-repeat-line size-4 shrink-0 text-text-secondary" />
<span className="min-w-0 flex-1 truncate system-md-regular text-text-secondary">
{t(($) => $['studio.changeVersion'])}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canRedeploy}
className="gap-2 px-2"
onClick={() => onRedeploy?.(row)}
>
<span
aria-hidden
className="i-custom-vender-other-replay-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate system-md-regular text-text-secondary">
{t(($) => $['deployTab.redeploy'])}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 px-2" onClick={() => setShowUndeployConfirm(true)}>
<span
aria-hidden
className="i-ri-logout-circle-r-line size-4 shrink-0 text-text-secondary"
/>
<span className="min-w-0 flex-1 truncate system-md-regular text-text-secondary">
{t(($) => $['deployTab.undeploy'])}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<UndeployConfirmDialog
environmentName={row.name}
open={showUndeployConfirm}
onConfirm={handleUndeploy}
onOpenChange={setShowUndeployConfirm}
/>
</>
)
}
function EnvironmentRow({
row,
onChangeVersion,
onDeployLatest,
onRedeploy,
onUndeploy,
}: {
row: MockEnvironmentDeployment
onChangeVersion?: (deployment: MockEnvironmentDeployment) => void
onDeployLatest?: (deployment: MockEnvironmentDeployment) => void
onRedeploy?: (deployment: MockEnvironmentDeployment) => void
onUndeploy?: (deployment: MockEnvironmentDeployment) => void
}) {
return (
<tr className="h-14 border-b border-divider-subtle hover:bg-state-base-hover">
<td className="border-b border-divider-subtle pr-2 pl-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg text-text-secondary">
<span aria-hidden className="i-ri-instance-line size-3.5" />
</span>
<span className="truncate system-md-medium text-text-secondary">{row.name}</span>
</div>
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<VersionLabel version={row.version} />
</td>
<td className="border-b border-divider-subtle px-2">
<DeploymentStatus status={row.status} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<ActivityCell activity={row.activity} />
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<div className="flex items-center gap-1">
{ACCESS_POINT_ORDER.map((accessPoint) => (
<AccessPointIcon
key={accessPoint}
accessPoint={accessPoint}
active={row.accessPoints.includes(accessPoint)}
/>
))}
</div>
</td>
<td className="border-b border-divider-subtle pr-2 pl-3">
<RowActions
row={row}
onChangeVersion={onChangeVersion}
onDeployLatest={onDeployLatest}
onRedeploy={onRedeploy}
onUndeploy={onUndeploy}
/>
</td>
</tr>
)
}
type EnvironmentTableProps = {
deployments?: MockEnvironmentDeployment[]
onChangeVersion?: (deployment: MockEnvironmentDeployment) => void
onDeployLatest?: (deployment: MockEnvironmentDeployment) => void
onDeployToEnvironment?: (environment: string) => void
onRedeploy?: (deployment: MockEnvironmentDeployment) => void
onUndeploy?: (deployment: MockEnvironmentDeployment) => void
}
export function EnvironmentTable({
deployments = MOCK_ENVIRONMENT_DEPLOYMENTS,
onChangeVersion,
onDeployLatest,
onDeployToEnvironment,
onRedeploy,
onUndeploy,
}: EnvironmentTableProps) {
const { t } = useTranslation('deployments')
const used = deployments.length
return (
<section
aria-labelledby="deploy-environments-title"
className="flex min-h-0 grow flex-col gap-3"
>
<div className="flex shrink-0 items-center justify-between">
<div className="flex min-w-0 items-center gap-2">
<h2
id="deploy-environments-title"
className="truncate system-md-semibold text-text-primary"
>
{t(($) => $['studio.environments'])}
</h2>
<span aria-hidden className="system-xs-regular text-text-quaternary">
·
</span>
<span className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.environmentsInUse'], {
total: MOCK_ENVIRONMENT_CAPACITY,
used,
})}
</span>
</div>
<EnvironmentDeployMenu onSelectEnvironment={onDeployToEnvironment} />
</div>
<div
className={cn(
'min-h-0 w-full grow overflow-y-auto',
deployments.length > 0 ? 'overflow-x-auto' : 'overflow-x-hidden',
)}
>
{deployments.length === 0 ? (
<EnvironmentTableEmpty onSelectEnvironment={onDeployToEnvironment} />
) : (
<table className="w-full min-w-260 table-fixed border-separate border-spacing-0">
<colgroup>
<col className="w-43" />
<col className="w-46" />
<col className="w-44" />
<col />
<col className="w-36" />
<col className="w-44" />
</colgroup>
<thead>
<tr className="sticky top-0 z-10 h-7 rounded-lg bg-background-section-burn">
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['deployTab.col.environment'])}
</th>
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.liveVersion'])}
</th>
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['deployTab.col.status'])}
</th>
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.lastActivity'])}
</th>
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.accessPoints'])}
</th>
<th className="pr-2 pl-3 text-left system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['deployTab.col.actions'])}
</th>
</tr>
</thead>
<tbody>
{deployments.map((row) => (
<EnvironmentRow
key={row.id}
row={row}
onChangeVersion={onChangeVersion}
onDeployLatest={onDeployLatest}
onRedeploy={onRedeploy}
onUndeploy={onUndeploy}
/>
))}
</tbody>
</table>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import type { DeploymentDialogRequest } from './deployment-dialog/types'
import type { MockEnvironmentDeployment } from './mock-data'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BuiltInEnvironmentCard } from './built-in-environment-card'
import { DeploymentDialog } from './deployment-dialog'
import { EnvironmentTable } from './environment-table'
import { BUILT_IN_ENVIRONMENT, MOCK_PUBLISHED_VERSIONS } from './mock-data'
export default function AppDeploy() {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
const [deploymentRequest, setDeploymentRequest] = useState<DeploymentDialogRequest>()
const latestVersion = MOCK_PUBLISHED_VERSIONS.find((version) => version.latest)
const handleRedeploy = (deployment: MockEnvironmentDeployment) => {
const failedVersionName =
deployment.action.kind === 'retry' ? deployment.action.version : undefined
const initialVersion = failedVersionName
? MOCK_PUBLISHED_VERSIONS.find((version) => version.name === failedVersionName)
: deployment.version
if (!initialVersion) return
setDeploymentRequest({
environment: deployment.name,
initialVersion,
kind: 'redeploy',
})
}
return (
<>
<main className="flex h-full flex-col bg-components-panel-bg">
<header className="shrink-0 px-6 pt-3 pb-2">
<h1 className="title-xl-semi-bold text-text-primary">
{tCommon(($) => $['appMenus.deploy'])}
</h1>
<p className="flex items-center gap-x-1 system-xs-regular text-text-tertiary">
<span>{t(($) => $['studio.description'])}</span>
<a
href="https://docs.dify.ai/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-text-accent hover:underline focus-visible:ring-1 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{tCommon(($) => $['operation.learnMore'])}
<span aria-hidden className="i-ri-arrow-right-up-line size-3" />
</a>
</p>
</header>
<div className="flex min-h-0 grow flex-col gap-4 px-6 py-2">
<BuiltInEnvironmentCard />
<EnvironmentTable
onDeployToEnvironment={(environment) =>
setDeploymentRequest({
currentVersion: BUILT_IN_ENVIRONMENT.version.name,
environment,
kind: 'deploy',
})
}
onChangeVersion={(deployment) =>
setDeploymentRequest({
currentVersion: deployment.version?.name,
environment: deployment.name,
kind: 'changeVersion',
})
}
onDeployLatest={(deployment) => {
if (!latestVersion) return
setDeploymentRequest({
currentVersion: deployment.version?.name,
environment: deployment.name,
initialVersion: latestVersion,
kind: 'deployLatest',
})
}}
onRedeploy={handleRedeploy}
onUndeploy={() => {}}
/>
</div>
</main>
<DeploymentDialog
request={deploymentRequest}
onClose={() => setDeploymentRequest(undefined)}
/>
</>
)
}

View File

@@ -0,0 +1,278 @@
export type MockDeploymentStatus = 'deploying' | 'failed' | 'running'
export type AccessPoint = 'mcp' | 'serviceApi' | 'trigger' | 'webApp'
export type MockActivity = {
actor: string
occurredAt: number
result: 'failed' | 'started' | 'succeeded'
target: string
}
export type MockVersion = {
behind?: number
description?: string
latest?: boolean
name: string
publishedAt: number
publishedBy: string
tags?: string[]
}
export type MockDeploymentCredential = {
category: 'Model' | 'Plugin'
id: 'github' | 'moonshot' | 'slack'
name: string
options: Array<{
label: string
value: string
}>
selectedValue: string
}
export type MockEnvironmentVariableValueSource = 'configured' | 'custom' | 'lastDeployed'
export type MockEnvironmentVariable = {
configuredValue: string
customValue: string
description: string
key: string
lastDeployedValue: string
source: MockEnvironmentVariableValueSource
valueType: 'number' | 'secret' | 'string'
}
export type MockRowAction =
| { disabled?: boolean; kind: 'changeVersion' }
| { kind: 'deployLatest' }
| { kind: 'redeploy' }
| { kind: 'retry'; version: string }
export type MockEnvironmentDeployment = {
accessPoints: AccessPoint[]
action: MockRowAction
activity: MockActivity
id: string
name: string
status: MockDeploymentStatus
version?: MockVersion
}
export const ACCESS_POINT_ORDER: readonly AccessPoint[] = ['webApp', 'serviceApi', 'mcp', 'trigger']
const MOCK_VERSION_DESCRIPTION =
'Fixed several critical bugs affecting data synchronization and optimized page loading speed. Enhanced system stability and user experience through backend improvements.'
const MOCK_VERSION_PUBLISHED_AT = Date.now() - 17 * 24 * 60 * 60 * 1000
function createMockVersion(
name: string,
overrides: Omit<Partial<MockVersion>, 'name'> = {},
): MockVersion {
return {
name,
publishedAt: MOCK_VERSION_PUBLISHED_AT,
publishedBy: 'Minco',
...overrides,
}
}
export const BUILT_IN_ENVIRONMENT: {
accessPoints: AccessPoint[]
actor: string
updatedAt: string
version: MockVersion
} = {
accessPoints: ['webApp', 'serviceApi'],
actor: 'Evan',
updatedAt: '07-01 09:00',
version: createMockVersion('Sprint-42', {
latest: true,
}),
}
export const MOCK_ENVIRONMENT_CAPACITY = 12
const MOCK_ACTIVITY_AT = Date.UTC(2026, 6, 25, 1)
export const MOCK_ENVIRONMENT_DEPLOYMENTS: MockEnvironmentDeployment[] = [
{
accessPoints: ['webApp', 'serviceApi', 'mcp'],
action: { disabled: true, kind: 'changeVersion' },
activity: { actor: 'Evan', occurredAt: MOCK_ACTIVITY_AT, result: 'started', target: '#1' },
id: 'staging',
name: 'Staging',
status: 'deploying',
},
{
accessPoints: ['webApp', 'serviceApi', 'mcp'],
action: { kind: 'changeVersion' },
activity: {
actor: 'Evan',
occurredAt: MOCK_ACTIVITY_AT,
result: 'succeeded',
target: 'Sprint-42',
},
id: 'canary',
name: 'Canary',
status: 'running',
version: createMockVersion('Sprint-42', {
description: MOCK_VERSION_DESCRIPTION,
latest: true,
}),
},
{
accessPoints: ['webApp', 'serviceApi'],
action: { kind: 'deployLatest' },
activity: { actor: 'Evan', occurredAt: MOCK_ACTIVITY_AT, result: 'succeeded', target: '#11' },
id: 'pre-release',
name: 'Pre-release',
status: 'running',
version: createMockVersion('Version-02', { behind: 1 }),
},
{
accessPoints: ['webApp', 'serviceApi'],
action: { kind: 'deployLatest' },
activity: {
actor: 'Rhonda',
occurredAt: MOCK_ACTIVITY_AT,
result: 'succeeded',
target: '#11',
},
id: 'prod',
name: 'Prod',
status: 'running',
version: createMockVersion('v0.9-hotfix', { behind: 1 }),
},
{
accessPoints: ['trigger'],
action: { kind: 'deployLatest' },
activity: { actor: 'Rhonda', occurredAt: MOCK_ACTIVITY_AT, result: 'failed', target: '#10' },
id: 'eu-prod',
name: 'EU-Prod',
status: 'running',
version: createMockVersion('v0.6-beta', { behind: 2 }),
},
{
accessPoints: ['trigger'],
action: { kind: 'redeploy' },
activity: {
actor: 'Rhonda',
occurredAt: MOCK_ACTIVITY_AT,
result: 'succeeded',
target: '#11',
},
id: 'qa',
name: 'QA',
status: 'running',
version: createMockVersion('v0.3-beta', { behind: 1 }),
},
{
accessPoints: ['trigger'],
action: { kind: 'redeploy' },
activity: {
actor: 'Rhonda',
occurredAt: MOCK_ACTIVITY_AT,
result: 'succeeded',
target: '#11',
},
id: 'sandbox',
name: 'Sandbox',
status: 'running',
version: createMockVersion('v0.3-beta', { behind: 5 }),
},
{
accessPoints: ['trigger'],
action: { kind: 'retry', version: 'Sprint-42' },
activity: {
actor: 'Rhonda',
occurredAt: MOCK_ACTIVITY_AT,
result: 'failed',
target: 'Sprint-42',
},
id: 'preview',
name: 'Preview',
status: 'failed',
},
]
export const MOCK_UNDEPLOYED_ENVIRONMENTS = ['Testing', 'Dev', 'Demo', 'US-Prod']
export const MOCK_PUBLISHED_VERSIONS: MockVersion[] = [
createMockVersion('#6', {
latest: true,
publishedBy: 'Evan',
tags: ['Pre-release', 'v0.9-hotfix'],
}),
createMockVersion('#5', {
description: MOCK_VERSION_DESCRIPTION,
publishedBy: 'Evan',
tags: ['Pre-release', 'v0.9-hotfix'],
}),
createMockVersion('Sprint-42', { publishedBy: 'Evan' }),
createMockVersion('Sprint-35', { publishedBy: 'Evan' }),
createMockVersion('Sprint-28', { publishedBy: 'Evan' }),
createMockVersion('Sprint-16', { publishedBy: 'Evan' }),
]
export const MOCK_DEPLOYMENT_CREDENTIALS: MockDeploymentCredential[] = [
{
category: 'Model',
id: 'moonshot',
name: 'Moonshot',
options: [
{ label: 'Enterprise key', value: 'enterprise' },
{ label: 'Development key', value: 'development' },
],
selectedValue: 'enterprise',
},
{
category: 'Plugin',
id: 'github',
name: 'GitHub',
options: [
{ label: 'GitHub OAuth Key', value: 'oauth' },
{ label: 'GitHub personal token', value: 'personal-token' },
],
selectedValue: 'oauth',
},
{
category: 'Plugin',
id: 'slack',
name: 'Slack',
options: [
{ label: 'Slack workspace OAuth', value: 'workspace-oauth' },
{ label: 'Slack bot token', value: 'bot-token' },
],
selectedValue: 'workspace-oauth',
},
]
export const MOCK_ENVIRONMENT_VARIABLES: MockEnvironmentVariable[] = [
{
configuredValue: '2',
customValue: '2',
description: 'Server port',
key: 'PORT',
lastDeployedValue: '1',
source: 'configured',
valueType: 'number',
},
{
configuredValue: 'sk-********abc',
customValue: '',
description: 'Invoking large language models to answer questions or process natural language',
key: 'API_KEY',
lastDeployedValue: 'sk-********xyz',
source: 'configured',
valueType: 'secret',
},
{
configuredValue: 'environment variable',
customValue: '',
description: '',
key: 'name',
lastDeployedValue: 'environment variable 01',
source: 'lastDeployed',
valueType: 'string',
},
]

View File

@@ -0,0 +1,57 @@
'use client'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { useTranslation } from 'react-i18next'
type UndeployConfirmDialogProps = {
environmentName: string
open: boolean
onConfirm: () => void
onOpenChange: (open: boolean) => void
}
export function UndeployConfirmDialog({
environmentName,
open,
onConfirm,
onOpenChange,
}: UndeployConfirmDialogProps) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-120">
<div className="flex flex-col items-start gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['deployTab.undeployConfirmTitle'], { name: environmentName })}
</AlertDialogTitle>
<AlertDialogDescription render={<div />} className="flex flex-col gap-2">
<p className="system-md-regular text-text-secondary">
{t(($) => $['deployTab.undeployConfirmDesc'])}
</p>
<p className="system-md-medium text-text-warning">
{t(($) => $['deployTab.undeployConfirmWarning'])}
</p>
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" className="min-w-20">
{tCommon(($) => $['operation.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton className="min-w-20" onClick={onConfirm}>
{t(($) => $['deployTab.confirmUndeploy'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import type { MockVersion } from './mock-data'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
export function VersionLabel({ version }: { version?: MockVersion }) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
if (!version) return <span className="text-text-quaternary">--</span>
const versionsBehindLabel =
version.behind === undefined
? ''
: version.behind === 1
? t(($) => $['studio.versionsBehind_one'], { count: version.behind })
: t(($) => $['studio.versionsBehind_other'], { count: version.behind })
return (
<div className="flex min-w-0 items-center gap-1">
<Popover>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
render={
<button
type="button"
className="min-w-0 cursor-help truncate border-b border-dotted border-text-quaternary system-md-medium text-text-secondary outline-hidden focus-visible:ring-1 focus-visible:ring-state-accent-solid"
>
{version.name}
</button>
}
/>
<PopoverContent
placement="top"
popupClassName="w-[296px] max-w-[calc(100vw-32px)] border-0 bg-components-tooltip-bg px-4 py-3.5 text-start inset-ring-[0.5px] inset-ring-components-panel-border backdrop-blur-[5px]"
>
<div className="flex flex-col gap-1">
<PopoverTitle className="system-sm-semibold text-text-secondary">
{version.name}
</PopoverTitle>
<p className="system-xs-regular whitespace-nowrap text-text-tertiary">
{t(($) => $['common.publishedBy'], {
ns: 'workflow',
time: formatTimeFromNow(version.publishedAt),
author: version.publishedBy,
})}
</p>
{version.description && (
<>
<span className="h-px w-4 bg-divider-regular my-1" />
<PopoverDescription className="system-xs-regular text-text-tertiary">
{version.description}
</PopoverDescription>
</>
)}
</div>
</PopoverContent>
</Popover>
{version.latest && (
<span className="inline-flex h-4.5 shrink-0 items-center rounded-[5px] border border-text-accent px-1 bg-components-badge-bg-dimm system-2xs-medium-uppercase text-text-accent">
{t(($) => $['overview.chip.latest'])}
</span>
)}
{version.behind !== undefined && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={versionsBehindLabel}
className="inline-flex h-4.5 shrink-0 cursor-default items-center rounded-[5px] border border-util-colors-orange-orange-500 px-1 system-2xs-medium text-util-colors-orange-orange-600 outline-hidden focus-visible:ring-1 focus-visible:ring-state-accent-solid"
>
<span aria-hidden className="i-ri-arrow-up-line size-3" />
{version.behind}
</button>
}
/>
<TooltipContent>{versionsBehindLabel}</TooltipContent>
</Tooltip>
)}
</div>
)
}

View File

@@ -151,6 +151,20 @@ describe('createFeaturesStore', () => {
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
expect(store.getState().features.opening?.enabled).toBe(true)
})
it('should notify publish tracking unless the update is silent', () => {
const onFeaturesChange = vi.fn()
const store = createFeaturesStore(undefined, onFeaturesChange)
const nextFeatures = {
moreLikeThis: { enabled: true },
}
store.getState().setFeatures(nextFeatures)
store.getState().setFeatures(nextFeatures, { silent: true })
expect(onFeaturesChange).toHaveBeenCalledOnce()
expect(onFeaturesChange).toHaveBeenCalledWith(nextFeatures)
})
})
describe('showFeaturesModal', () => {

View File

@@ -1,4 +1,5 @@
import type { FeaturesState, FeaturesStore } from './store'
import type { Features } from './types'
import { createContext, useRef } from 'react'
import { createFeaturesStore } from './store'
@@ -6,11 +7,21 @@ export const FeaturesContext = createContext<FeaturesStore | null>(null)
type FeaturesProviderProps = {
children: React.ReactNode
onFeaturesChange?: (features: Features) => void
} & Partial<FeaturesState>
export const FeaturesProvider = ({ children, ...props }: FeaturesProviderProps) => {
export const FeaturesProvider = ({
children,
onFeaturesChange,
...props
}: FeaturesProviderProps) => {
const storeRef = useRef<FeaturesStore | undefined>(undefined)
const onFeaturesChangeRef = useRef(onFeaturesChange)
onFeaturesChangeRef.current = onFeaturesChange
if (!storeRef.current) storeRef.current = createFeaturesStore(props)
if (!storeRef.current)
storeRef.current = createFeaturesStore(props, (features) =>
onFeaturesChangeRef.current?.(features),
)
return <FeaturesContext.Provider value={storeRef.current}>{children}</FeaturesContext.Provider>
}

View File

@@ -35,8 +35,8 @@ const AnnotationReply = ({ disabled, onChange }: Props) => {
const newFeatures = produce(features, (draft) => {
draft.annotationReply = newConfig
})
setFeatures(newFeatures)
if (onChange) onChange(newFeatures)
setFeatures(newFeatures, { silent: true })
onChange?.()
},
[featuresStore, onChange],
)

View File

@@ -12,14 +12,21 @@ export type FeaturesState = {
}
type FeaturesAction = {
setFeatures: (features: Features) => void
setFeatures: (features: Features, options?: SetFeaturesOptions) => void
}
export type FeatureStoreState = FeaturesState & FeaturesAction & FeaturesModal
export type FeaturesStore = ReturnType<typeof createFeaturesStore>
export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
export type SetFeaturesOptions = {
silent?: boolean
}
export const createFeaturesStore = (
initProps?: Partial<FeaturesState>,
onFeaturesChange?: (features: Features) => void,
) => {
const DEFAULT_PROPS: FeaturesState = {
features: {
moreLikeThis: {
@@ -59,7 +66,10 @@ export const createFeaturesStore = (initProps?: Partial<FeaturesState>) => {
return createStore<FeatureStoreState>()((set) => ({
...DEFAULT_PROPS,
...initProps,
setFeatures: (features) => set(() => ({ features })),
setFeatures: (features, options) => {
set(() => ({ features }))
if (!options?.silent) onFeaturesChange?.(features)
},
showFeaturesModal: false,
setShowFeaturesModal: (showFeaturesModal) => set(() => ({ showFeaturesModal })),
}))

View File

@@ -51,6 +51,7 @@ const FeaturesTrigger = () => {
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore((s) => s.publishedAt)
const draftUpdatedAt = useStore((s) => s.draftUpdatedAt)
const draftHash = useStore((s) => s.syncWorkflowDraftHash)
const toolPublished = useStore((s) => s.toolPublished)
const lastPublishedHasUserInput = useStore((s) => s.lastPublishedHasUserInput)
@@ -253,6 +254,7 @@ const FeaturesTrigger = () => {
{...{
publishedAt,
draftUpdatedAt,
draftHash,
disabled: nodesReadOnly || !hasWorkflowNodes || !canReleaseAndVersion,
toolPublished,
inputs: variables,

View File

@@ -1,7 +1,7 @@
import { waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockEnum } from '@/app/components/workflow/types'
import { renderHook } from '@/test/console/render'
import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data'
import { AppACLPermission } from '@/utils/permission'
import { useWorkflowInit } from '../use-workflow-init'
@@ -72,6 +72,10 @@ vi.mock('../use-workflow-template', () => ({
}))
vi.mock('@/service/use-workflow', () => ({
appWorkflowQueryOptions: (appId: string) => ({
queryKey: ['workflow', 'publish', appId],
queryFn: () => mockFetchPublishedWorkflow(`/apps/${appId}/workflows/publish`),
}),
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
if (workflowConfigState.data) onSuccess(workflowConfigState.data)
return workflowConfigState
@@ -84,7 +88,6 @@ vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
}))
const notExistError = () => ({
@@ -352,14 +355,45 @@ describe('useWorkflowInit', () => {
expect(result.current.isLoading).toBe(false)
})
it('should fall back to no published user input when preload requests fail', async () => {
it('should keep published metadata when loading node defaults fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
mockFetchPublishedWorkflow.mockResolvedValue({
created_at: 99,
graph: {
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
edges: [{ source: 'start', target: 'end' }],
},
})
renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
})
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('should keep node defaults when loading published metadata fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
mockFetchNodesDefaultConfigs.mockResolvedValue([
{ type: 'start', config: { title: 'Start Config' } },
])
mockFetchPublishedWorkflow.mockRejectedValue(new Error('published workflow failed'))
renderHook(() => useWorkflowInit())
await waitFor(() => {
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
nodesDefaultConfigs: {
start: { title: 'Start Config' },
},
})
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
})

View File

@@ -1,6 +1,7 @@
import type { Edge, Node } from '@/app/components/workflow/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
@@ -8,13 +9,8 @@ import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { userProfileIdAtom } from '@/context/account-state'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useWorkflowConfig } from '@/service/use-workflow'
import {
fetchNodesDefaultConfigs,
fetchPublishedWorkflow,
fetchWorkflowDraft,
syncWorkflowDraft,
} from '@/service/workflow'
import { appWorkflowQueryOptions, useWorkflowConfig } from '@/service/use-workflow'
import { fetchNodesDefaultConfigs, fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas'
@@ -58,6 +54,7 @@ const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean
}
export const useWorkflowInit = () => {
const queryClient = useQueryClient()
const workflowStore = useWorkflowStore()
const { nodes: nodesTemplate, edges: edgesTemplate } = useWorkflowTemplate()
const appDetail = useAppStore((state) => state.appDetail)!
@@ -190,13 +187,13 @@ export const useWorkflowInit = () => {
}, [])
const handleFetchPreloadData = useCallback(async () => {
try {
const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(
`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`,
)
const publishedWorkflow = await fetchPublishedWorkflow(
`/apps/${appDetail?.id}/workflows/publish`,
)
const [nodesDefaultConfigsResult, publishedWorkflowResult] = await Promise.allSettled([
fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`),
queryClient.fetchQuery(appWorkflowQueryOptions(appDetail.id)),
])
if (nodesDefaultConfigsResult.status === 'fulfilled') {
const nodesDefaultConfigsData = nodesDefaultConfigsResult.value
workflowStore.setState({
nodesDefaultConfigs: nodesDefaultConfigsData.reduce(
(acc, block) => {
@@ -206,16 +203,22 @@ export const useWorkflowInit = () => {
{} as Record<string, unknown>,
),
})
} else {
console.error(nodesDefaultConfigsResult.reason)
}
if (publishedWorkflowResult.status === 'fulfilled') {
const publishedWorkflow = publishedWorkflowResult.value
workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0)
const graph = publishedWorkflow?.graph
workflowStore
.getState()
.setLastPublishedHasUserInput(hasConnectedUserInput(graph?.nodes, graph?.edges))
} catch (e) {
console.error(e)
} else {
console.error(publishedWorkflowResult.reason)
workflowStore.getState().setLastPublishedHasUserInput(false)
}
}, [workflowStore, appDetail])
}, [workflowStore, appDetail, queryClient])
useEffect(() => {
handleFetchPreloadData()

View File

@@ -11,6 +11,8 @@ const mockHandleLoadBackupDraft = vi.fn()
const mockHandleRefreshWorkflowDraft = vi.fn()
const mockHandleExportDSL = vi.fn()
const mockRestoreWorkflow = vi.fn()
const mockUpdateWorkflow = vi.fn()
const mockInvalidateAppWorkflow = vi.fn()
const mockSetCurrentVersion = vi.fn()
const mockSetShowWorkflowVersionHistoryPanel = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
@@ -83,10 +85,11 @@ vi.mock('@/context/provider-context', () => ({
vi.mock('@/service/use-workflow', () => ({
useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }),
useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow,
useInvalidAllLastRun: () => vi.fn(),
useResetWorkflowVersionHistory: () => vi.fn(),
useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }),
useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }),
useUpdateWorkflow: () => ({ mutateAsync: mockUpdateWorkflow }),
useWorkflowVersionHistory: () => ({
data: {
pages: [
@@ -128,10 +131,19 @@ vi.mock('../../../hooks/use-workflow-run', () => ({
}))
vi.mock('../../../hooks-store', () => ({
useHooksStore: () => ({
flowId: 'test-flow-id',
flowType: 'workflow',
}),
useHooksStore: (
selector: (state: {
accessControl: { canImportExportDSL: boolean }
configsMap: { flowId: string; flowType: string }
}) => unknown,
) =>
selector({
accessControl: { canImportExportDSL: true },
configsMap: {
flowId: 'app-1',
flowType: 'appFlow',
},
}),
}))
vi.mock('../../../collaboration/core/collaboration-manager', () => ({
@@ -180,7 +192,25 @@ vi.mock('../restore-confirm-modal', () => ({
}))
vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({
default: () => null,
default: ({
versionInfo,
onPublish,
}: {
versionInfo: VersionHistory
onPublish: (params: { id?: string; title: string; releaseNotes: string }) => Promise<void>
}) => (
<button
onClick={() =>
onPublish({
id: versionInfo.id,
title: 'Updated release',
releaseNotes: 'Updated notes',
})
}
>
submit version info
</button>
),
}))
vi.mock('../version-history-item', () => ({
@@ -213,6 +243,11 @@ vi.mock('../version-history-item', () => ({
>
{`export-${item.id}`}
</button>
<button
onClick={() => handleClickActionMenuItem(VersionHistoryContextMenuOptions.edit)}
>
{`edit-${item.id}`}
</button>
</>
)}
</div>
@@ -227,6 +262,7 @@ describe('VersionHistoryPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRestoreWorkflow.mockResolvedValue(undefined)
mockUpdateWorkflow.mockResolvedValue(undefined)
mockCurrentVersion = null
mockPlanType = Plan.professional
mockEnableBilling = true
@@ -369,4 +405,35 @@ describe('VersionHistoryPanel', () => {
expect(mockSetCurrentVersion).not.toHaveBeenCalled()
expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled()
})
it('should refresh the published workflow after editing the latest app version', async () => {
mockUpdateWorkflow.mockImplementation(
async (
_params,
options?: {
onSuccess?: () => void
onSettled?: () => void
},
) => {
options?.onSuccess?.()
options?.onSettled?.()
},
)
const { VersionHistoryPanel } = await import('../index')
render(
<VersionHistoryPanel
latestVersionId="published-version-id"
restoreVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}/restore`}
updateVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}`}
/>,
)
fireEvent.click(screen.getByText('edit-published-version-id'))
fireEvent.click(screen.getByRole('button', { name: 'submit version info' }))
await waitFor(() => {
expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1')
})
})
})

View File

@@ -83,6 +83,23 @@ describe('VersionHistoryItem', () => {
// Published items should expose metadata and the hover context menu.
describe('Published Items', () => {
it('should show the mocked deployed environments', () => {
render(
<VersionHistoryItem
item={createVersionHistory()}
currentVersion={null}
latestVersionId="version-1"
onClick={vi.fn()}
handleClickActionMenuItem={vi.fn()}
canImportExportDSL
isLast={false}
/>,
)
expect(screen.getByText('Pre-release')).toBeInTheDocument()
expect(screen.getByText('QA')).toBeInTheDocument()
})
it('should open the context menu for a latest named version and forward restore', async () => {
const user = userEvent.setup()
const handleClickActionMenuItem = vi.fn()
@@ -116,6 +133,7 @@ describe('VersionHistoryItem', () => {
expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument()
expect(screen.getByText('app.export')).toBeInTheDocument()
expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument()
expect(screen.getByText('version-1')).toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer')

View File

@@ -43,6 +43,7 @@ describe('ActionMenu', () => {
renderActionMenu(
<ActionMenu
workflowId="version-1"
isNamedVersion
isShowDelete
canImportExportDSL
@@ -52,7 +53,10 @@ describe('ActionMenu', () => {
/>,
)
await user.click(screen.getByRole('button'))
const trigger = screen.getByRole('button', { name: 'common.operation.more' })
expect(trigger).not.toHaveAttribute('role')
await user.click(trigger)
await user.click(screen.getByText('workflow.common.restore'))
await user.click(screen.getByText('common.operation.delete'))
@@ -74,6 +78,7 @@ describe('ActionMenu', () => {
renderActionMenu(
<ActionMenu
workflowId="version-1"
isNamedVersion
isShowDelete
canImportExportDSL

View File

@@ -6,6 +6,7 @@ describe('useActionMenu', () => {
it('returns restore, edit, export, copy and delete operations for app workflows', () => {
const { result } = renderWorkflowHook(() =>
useActionMenu({
workflowId: 'version-1',
isNamedVersion: true,
canImportExportDSL: true,
isShowDelete: false,
@@ -31,6 +32,7 @@ describe('useActionMenu', () => {
const { result } = renderWorkflowHook(
() =>
useActionMenu({
workflowId: 'version-1',
isNamedVersion: false,
canImportExportDSL: true,
isShowDelete: true,
@@ -57,6 +59,7 @@ describe('useActionMenu', () => {
{
key: VersionHistoryContextMenuOptions.copyId,
name: 'workflow.versionHistory.copyId',
description: 'version-1',
},
])
})
@@ -64,6 +67,7 @@ describe('useActionMenu', () => {
it('omits export when import/export DSL permission is missing', () => {
const { result } = renderWorkflowHook(() =>
useActionMenu({
workflowId: 'version-1',
isNamedVersion: true,
canImportExportDSL: false,
isShowDelete: false,

View File

@@ -9,6 +9,7 @@ type ActionMenuItemProps = {
item: {
key: VersionHistoryContextMenuOptions
name: string
description?: string
showUpgrade?: boolean
}
onClick: (operation: VersionHistoryContextMenuOptions) => void
@@ -21,6 +22,7 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive
variant={isDestructive ? 'destructive' : 'default'}
className={cn(
'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap',
item.description && 'h-auto py-1',
isDestructive && 'data-highlighted:bg-state-destructive-hover',
)}
onClick={(event) => {
@@ -33,11 +35,20 @@ const ActionMenuItem: FC<ActionMenuItemProps> = ({ item, onClick, isDestructive
>
<div
className={cn(
'flex-1 system-md-regular whitespace-nowrap text-text-primary',
'min-w-0 flex-1 system-md-regular whitespace-nowrap text-text-primary',
item.description && 'flex flex-col gap-y-0.5 px-1 py-0.5 text-text-secondary',
isDestructive && 'text-inherit',
)}
>
{item.name}
<div className="w-full truncate">{item.name}</div>
{item.description && (
<div
className="w-full max-w-[152px] truncate system-2xs-regular text-text-tertiary"
title={item.description}
>
{item.description}
</div>
)}
</div>
{item.showUpgrade && (
<div data-upgrade-action className="shrink-0">

View File

@@ -6,13 +6,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { VersionHistoryContextMenuOptions } from '../../../types'
import ActionMenuItem from './action-menu-item'
import useActionMenu from './use-action-menu'
export type ActionMenuProps = {
workflowId: string
isShowDelete: boolean
isNamedVersion: boolean
canImportExportDSL: boolean
@@ -24,21 +25,21 @@ export type ActionMenuProps = {
const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props
const { deleteOperation, options } = useActionMenu(props)
const { t } = useTranslation()
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
nativeButton={false}
render={
<Button
nativeButton={false}
size="small"
className="px-1"
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
onClick={(e) => e.stopPropagation()}
/>
}
>
<RiMoreFill className="size-4" />
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
@@ -54,7 +55,7 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
))}
{isShowDelete && (
<>
<DropdownMenuSeparator className="my-0" />
<DropdownMenuSeparator className="my-1" />
<ActionMenuItem
item={deleteOperation}
isDestructive

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