Compare commits
18 Commits
codex/refa
...
build/appd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608046d628 | ||
|
|
414f8a4e36 | ||
|
|
0cb0369b7e | ||
|
|
63c1a53cf9 | ||
|
|
370a36531f | ||
|
|
0b84445195 | ||
|
|
be706389b3 | ||
|
|
4968d0cf45 | ||
|
|
d8bcc2b14c | ||
|
|
4f8f47b6d1 | ||
|
|
6e75e7c5b0 | ||
|
|
8c91bce282 | ||
|
|
24a66293fc | ||
|
|
cc82b275ee | ||
|
|
be88e6adda | ||
|
|
3b832da46a | ||
|
|
8812fd649c | ||
|
|
39915f91e6 |
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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\"/>"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 331,
|
||||
"total": 334,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppAccessPointDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppDetailSidebarSlot } from '../sidebar-page'
|
||||
|
||||
export default function AppDeployDetailSidebarSlot() {
|
||||
return <AppDetailSidebarSlot />
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import AppDeploy from '@/app/components/app/deploy'
|
||||
|
||||
export default function AppDeployPage() {
|
||||
return <AppDeploy />
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
104
web/app/components/app/access-point/__tests__/index.spec.tsx
Normal file
104
web/app/components/app/access-point/__tests__/index.spec.tsx
Normal 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()
|
||||
},
|
||||
)
|
||||
})
|
||||
159
web/app/components/app/access-point/access-point-card.tsx
Normal file
159
web/app/components/app/access-point/access-point-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
web/app/components/app/access-point/access-point-url.tsx
Normal file
134
web/app/components/app/access-point/access-point-url.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
154
web/app/components/app/access-point/built-in-access-points.tsx
Normal file
154
web/app/components/app/access-point/built-in-access-points.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
102
web/app/components/app/access-point/index.tsx
Normal file
102
web/app/components/app/access-point/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
web/app/components/app/access-point/mcp-card.tsx
Normal file
233
web/app/components/app/access-point/mcp-card.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
78
web/app/components/app/access-point/service-api-card.tsx
Normal file
78
web/app/components/app/access-point/service-api-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
web/app/components/app/access-point/trigger-card.tsx
Normal file
220
web/app/components/app/access-point/trigger-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
web/app/components/app/access-point/use-built-in-actions.ts
Normal file
132
web/app/components/app/access-point/use-built-in-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
53
web/app/components/app/access-point/utils.ts
Normal file
53
web/app/components/app/access-point/utils.ts
Normal 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
|
||||
}
|
||||
280
web/app/components/app/access-point/web-app-card.tsx
Normal file
280
web/app/components/app/access-point/web-app-card.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
30
web/app/components/app/app-publisher/action-tooltip.tsx
Normal file
30
web/app/components/app/app-publisher/action-tooltip.tsx
Normal 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
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
201
web/app/components/app/app-publisher/environment-sections.tsx
Normal file
201
web/app/components/app/app-publisher/environment-sections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
475
web/app/components/app/app-publisher/environment-tabs.tsx
Normal file
475
web/app/components/app/app-publisher/environment-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
web/app/components/app/app-publisher/events.ts
Normal file
1
web/app/components/app/app-publisher/events.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const APP_PUBLISH_DRAFT_CHANGED = 'APP_PUBLISH_DRAFT_CHANGED'
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1143,6 +1143,7 @@ describe('Debug', () => {
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
{ silent: true },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ const Debug: FC<IDebug> = ({
|
||||
enabled: supportedVision,
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setFeatures(newFeatures, { silent: true })
|
||||
}
|
||||
}, [debugWithMultipleModel, featuresStore, mode, multipleModelConfigs, textGenerationModelList])
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
356
web/app/components/app/deploy/__tests__/index.spec.tsx
Normal file
356
web/app/components/app/deploy/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
63
web/app/components/app/deploy/access-point-icon.tsx
Normal file
63
web/app/components/app/deploy/access-point-icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
web/app/components/app/deploy/assets/github.png
Normal file
BIN
web/app/components/app/deploy/assets/github.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
web/app/components/app/deploy/assets/moonshot.png
Normal file
BIN
web/app/components/app/deploy/assets/moonshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
web/app/components/app/deploy/assets/slack.png
Normal file
BIN
web/app/components/app/deploy/assets/slack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
77
web/app/components/app/deploy/built-in-environment-card.tsx
Normal file
77
web/app/components/app/deploy/built-in-environment-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
57
web/app/components/app/deploy/deployment-dialog/index.tsx
Normal file
57
web/app/components/app/deploy/deployment-dialog/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
web/app/components/app/deploy/deployment-dialog/types.ts
Normal file
16
web/app/components/app/deploy/deployment-dialog/types.ts
Normal 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
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
web/app/components/app/deploy/deployment-status.tsx
Normal file
38
web/app/components/app/deploy/deployment-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
web/app/components/app/deploy/environment-deploy-menu.tsx
Normal file
67
web/app/components/app/deploy/environment-deploy-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
344
web/app/components/app/deploy/environment-table-empty.tsx
Normal file
344
web/app/components/app/deploy/environment-table-empty.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
363
web/app/components/app/deploy/environment-table.tsx
Normal file
363
web/app/components/app/deploy/environment-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
web/app/components/app/deploy/index.tsx
Normal file
93
web/app/components/app/deploy/index.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
278
web/app/components/app/deploy/mock-data.ts
Normal file
278
web/app/components/app/deploy/mock-data.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
57
web/app/components/app/deploy/undeploy-confirm-dialog.tsx
Normal file
57
web/app/components/app/deploy/undeploy-confirm-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
web/app/components/app/deploy/version-label.tsx
Normal file
94
web/app/components/app/deploy/version-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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 })),
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user