mirror of
https://github.com/langgenius/dify.git
synced 2026-01-06 14:35:59 +00:00
Compare commits
4 Commits
feat/group
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b128048c4 | ||
|
|
f57aa08a3f | ||
|
|
44d7aaaf33 | ||
|
|
7beed12eab |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -209,7 +209,6 @@ api/.vscode
|
||||
.history
|
||||
|
||||
.idea/
|
||||
web/migration/
|
||||
|
||||
# pnpm
|
||||
/.pnpm-store
|
||||
|
||||
@@ -63,7 +63,6 @@ class NodeType(StrEnum):
|
||||
TRIGGER_SCHEDULE = "trigger-schedule"
|
||||
TRIGGER_PLUGIN = "trigger-plugin"
|
||||
HUMAN_INPUT = "human-input"
|
||||
GROUP = "group"
|
||||
|
||||
@property
|
||||
def is_trigger_node(self) -> bool:
|
||||
|
||||
@@ -305,14 +305,7 @@ class Graph:
|
||||
if not node_configs:
|
||||
raise ValueError("Graph must have at least one node")
|
||||
|
||||
# Filter out UI-only node types:
|
||||
# - custom-note: top-level type (node_config.type == "custom-note")
|
||||
# - group: data-level type (node_config.data.type == "group")
|
||||
node_configs = [
|
||||
node_config for node_config in node_configs
|
||||
if node_config.get("type", "") != "custom-note"
|
||||
and node_config.get("data", {}).get("type", "") != "group"
|
||||
]
|
||||
node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"]
|
||||
|
||||
# Parse node configurations
|
||||
node_configs_map = cls._parse_node_configs(node_configs)
|
||||
|
||||
@@ -1534,7 +1534,7 @@ class OperationLog(TypeBase):
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content: Mapped[Any] = mapped_column(sa.JSON)
|
||||
content: Mapped[Any | None] = mapped_column(sa.JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ class Provider(TypeBase):
|
||||
|
||||
quota_type: Mapped[str | None] = mapped_column(String(40), nullable=True, server_default=text("''"), default="")
|
||||
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=None)
|
||||
quota_used: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
|
||||
quota_used: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=0)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
|
||||
@@ -415,7 +415,7 @@ class AppTrigger(TypeBase):
|
||||
node_id: Mapped[str | None] = mapped_column(String(64), nullable=False)
|
||||
trigger_type: Mapped[str] = mapped_column(EnumText(AppTriggerType, length=50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_name: Mapped[str] = mapped_column(String(255), server_default="", default="") # why it is nullable?
|
||||
provider_name: Mapped[str | None] = mapped_column(String(255), nullable=True, server_default="", default="")
|
||||
status: Mapped[str] = mapped_column(
|
||||
EnumText(AppTriggerStatus, length=50), nullable=False, default=AppTriggerStatus.ENABLED
|
||||
)
|
||||
|
||||
41
web/app/components/apps/app-card-skeleton.tsx
Normal file
41
web/app/components/apps/app-card-skeleton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
|
||||
type AppCardSkeletonProps = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton placeholder for App cards during loading states.
|
||||
* Matches the visual layout of AppCard component.
|
||||
*/
|
||||
export const AppCardSkeleton = React.memo(({ count = 6 }: AppCardSkeletonProps) => {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-[160px] rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg p-4"
|
||||
>
|
||||
<SkeletonContainer className="h-full">
|
||||
<SkeletonRow>
|
||||
<SkeletonRectangle className="h-10 w-10 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<SkeletonRectangle className="h-4 w-2/3" />
|
||||
<SkeletonRectangle className="h-3 w-1/3" />
|
||||
</div>
|
||||
</SkeletonRow>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<SkeletonRectangle className="h-3 w-full" />
|
||||
<SkeletonRectangle className="h-3 w-4/5" />
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
AppCardSkeleton.displayName = 'AppCardSkeleton'
|
||||
@@ -27,7 +27,9 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
@@ -45,7 +47,7 @@ const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
@@ -89,6 +91,7 @@ const List = () => {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -172,6 +175,8 @@ const List = () => {
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -205,23 +210,34 @@ const List = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasAnyApp
|
||||
? (
|
||||
<div className="relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />}
|
||||
{pages.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
)))}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6">
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} className="z-10" onSuccess={refetch} selectedAppType={activeTab} />}
|
||||
<Empty />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))
|
||||
}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
|
||||
@@ -25,6 +25,7 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
|
||||
export type CreateAppCardProps = {
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
onSuccess?: () => void
|
||||
ref: React.RefObject<HTMLDivElement | null>
|
||||
selectedAppType?: string
|
||||
@@ -33,6 +34,7 @@ export type CreateAppCardProps = {
|
||||
const CreateAppCard = ({
|
||||
ref,
|
||||
className,
|
||||
isLoading = false,
|
||||
onSuccess,
|
||||
selectedAppType,
|
||||
}: CreateAppCardProps) => {
|
||||
@@ -56,7 +58,11 @@ const CreateAppCard = ({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg', className)}
|
||||
className={cn(
|
||||
'relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity',
|
||||
isLoading && 'pointer-events-none opacity-50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="grow rounded-t-xl p-2">
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('createApp', { ns: 'app' })}</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
GetValuesOptions,
|
||||
} from '../types'
|
||||
import { useCallback } from 'react'
|
||||
import { getTransformedValuesWhenSecretInputPristine } from '../utils'
|
||||
import { getTransformedValuesWhenSecretInputPristine } from '../utils/secret-input'
|
||||
import { useCheckValidated } from './use-check-validated'
|
||||
|
||||
export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './secret-input'
|
||||
22
web/app/components/base/form/utils/zod-submit-validator.ts
Normal file
22
web/app/components/base/form/utils/zod-submit-validator.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ZodSchema } from 'zod'
|
||||
|
||||
type SubmitValidator<T> = ({ value }: { value: T }) => { fields: Record<string, string> } | undefined
|
||||
|
||||
export const zodSubmitValidator = <T>(schema: ZodSchema<T>): SubmitValidator<T> => {
|
||||
return ({ value }) => {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path[0]
|
||||
if (path === undefined)
|
||||
continue
|
||||
const key = String(path)
|
||||
if (!fieldErrors[key])
|
||||
fieldErrors[key] = issue.message
|
||||
}
|
||||
return { fields: fieldErrors }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CornerLabel from '@/app/components/base/corner-label'
|
||||
|
||||
type CornerLabelsProps = {
|
||||
dataset: DataSet
|
||||
}
|
||||
|
||||
const CornerLabels = ({ dataset }: CornerLabelsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!dataset.embedding_available) {
|
||||
return (
|
||||
<CornerLabel
|
||||
label={t('cornerLabel.unavailable', { ns: 'dataset' })}
|
||||
className="absolute right-0 top-0 z-10"
|
||||
labelClassName="rounded-tr-xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (dataset.runtime_mode === 'rag_pipeline') {
|
||||
return (
|
||||
<CornerLabel
|
||||
label={t('cornerLabel.pipeline', { ns: 'dataset' })}
|
||||
className="absolute right-0 top-0 z-10"
|
||||
labelClassName="rounded-tr-xl"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default React.memo(CornerLabels)
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { RiFileTextFill, RiRobot2Fill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external'
|
||||
|
||||
type DatasetCardFooterProps = {
|
||||
dataset: DataSet
|
||||
}
|
||||
|
||||
const DatasetCardFooter = ({ dataset }: DatasetCardFooterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
|
||||
|
||||
const documentCount = useMemo(() => {
|
||||
const availableDocCount = dataset.total_available_documents ?? 0
|
||||
if (availableDocCount < dataset.document_count)
|
||||
return `${availableDocCount} / ${dataset.document_count}`
|
||||
return `${dataset.document_count}`
|
||||
}, [dataset.document_count, dataset.total_available_documents])
|
||||
|
||||
const documentCountTooltip = useMemo(() => {
|
||||
const availableDocCount = dataset.total_available_documents ?? 0
|
||||
if (availableDocCount < dataset.document_count)
|
||||
return t('partialEnabled', { ns: 'dataset', count: dataset.document_count, num: availableDocCount })
|
||||
return t('docAllEnabled', { ns: 'dataset', count: availableDocCount })
|
||||
}, [t, dataset.document_count, dataset.total_available_documents])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-3 px-4 pb-3 pt-2 text-text-tertiary',
|
||||
!dataset.embedding_available && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
<Tooltip popupContent={documentCountTooltip}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiFileTextFill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{documentCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!isExternalProvider && (
|
||||
<Tooltip popupContent={`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiRobot2Fill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{dataset.app_count}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="system-xs-regular text-divider-deep">/</span>
|
||||
<span className="system-xs-regular">{`${t('updated', { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DatasetCardFooter)
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external'
|
||||
|
||||
type DatasetCardHeaderProps = {
|
||||
dataset: DataSet
|
||||
}
|
||||
|
||||
// DocModeInfo component - placed before usage
|
||||
type DocModeInfoProps = {
|
||||
dataset: DataSet
|
||||
isExternalProvider: boolean
|
||||
isShowDocModeInfo: boolean
|
||||
}
|
||||
|
||||
const DocModeInfo = ({
|
||||
dataset,
|
||||
isExternalProvider,
|
||||
isShowDocModeInfo,
|
||||
}: DocModeInfoProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
|
||||
if (isExternalProvider) {
|
||||
return (
|
||||
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
|
||||
<span>{t('externalKnowledgeBase', { ns: 'dataset' })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isShowDocModeInfo)
|
||||
return null
|
||||
|
||||
const indexingText = dataset.indexing_technique
|
||||
? formatIndexingTechniqueAndMethod(
|
||||
dataset.indexing_technique as 'economy' | 'high_quality',
|
||||
dataset.retrieval_model_dict?.search_method as Parameters<typeof formatIndexingTechniqueAndMethod>[1],
|
||||
)
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
|
||||
{dataset.doc_form && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
|
||||
>
|
||||
{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
|
||||
</span>
|
||||
)}
|
||||
{dataset.indexing_technique && indexingText && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={indexingText}
|
||||
>
|
||||
{indexingText}
|
||||
</span>
|
||||
)}
|
||||
{dataset.is_multimodal && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t('multimodal', { ns: 'dataset' })}
|
||||
>
|
||||
{t('multimodal', { ns: 'dataset' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main DatasetCardHeader component
|
||||
const DatasetCardHeader = ({ dataset }: DatasetCardHeaderProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
|
||||
|
||||
const isShowChunkingModeIcon = dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
|
||||
const isShowDocModeInfo = Boolean(
|
||||
dataset.doc_form
|
||||
&& dataset.indexing_technique
|
||||
&& dataset.retrieval_model_dict?.search_method
|
||||
&& (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published),
|
||||
)
|
||||
|
||||
const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
|
||||
const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
|
||||
|
||||
const iconInfo = useMemo(() => dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}, [dataset.icon_info])
|
||||
|
||||
const editTimeText = useMemo(
|
||||
() => `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`,
|
||||
[t, dataset.updated_at, formatTimeFromNow],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-x-3 px-4 pb-2 pt-4', !dataset.embedding_available && 'opacity-30')}>
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
|
||||
/>
|
||||
{(isShowChunkingModeIcon || isExternalProvider) && (
|
||||
<div className="absolute -bottom-1 -right-1 z-[5]">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-y-1 overflow-hidden py-px">
|
||||
<div
|
||||
className="system-md-semibold truncate text-text-secondary"
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary">
|
||||
<div className="truncate" title={dataset.author_name}>{dataset.author_name}</div>
|
||||
<div>·</div>
|
||||
<div className="truncate" title={editTimeText}>{editTimeText}</div>
|
||||
</div>
|
||||
<DocModeInfo
|
||||
dataset={dataset}
|
||||
isExternalProvider={isExternalProvider}
|
||||
isShowDocModeInfo={isShowDocModeInfo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DatasetCardHeader)
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import RenameDatasetModal from '../../../rename-modal'
|
||||
|
||||
type ModalState = {
|
||||
showRenameModal: boolean
|
||||
showConfirmDelete: boolean
|
||||
confirmMessage: string
|
||||
}
|
||||
|
||||
type DatasetCardModalsProps = {
|
||||
dataset: DataSet
|
||||
modalState: ModalState
|
||||
onCloseRename: () => void
|
||||
onCloseConfirm: () => void
|
||||
onConfirmDelete: () => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
const DatasetCardModals = ({
|
||||
dataset,
|
||||
modalState,
|
||||
onCloseRename,
|
||||
onCloseConfirm,
|
||||
onConfirmDelete,
|
||||
onSuccess,
|
||||
}: DatasetCardModalsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalState.showRenameModal && (
|
||||
<RenameDatasetModal
|
||||
show={modalState.showRenameModal}
|
||||
dataset={dataset}
|
||||
onClose={onCloseRename}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
)}
|
||||
{modalState.showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
|
||||
content={modalState.confirmMessage}
|
||||
isShow={modalState.showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onCloseConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DatasetCardModals)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type DescriptionProps = {
|
||||
dataset: DataSet
|
||||
}
|
||||
|
||||
const Description = ({ dataset }: DescriptionProps) => (
|
||||
<div
|
||||
className={cn('system-xs-regular line-clamp-2 h-10 px-4 py-1 text-text-tertiary', !dataset.embedding_available && 'opacity-30')}
|
||||
title={dataset.description}
|
||||
>
|
||||
{dataset.description}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default React.memo(Description)
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Operations from '../operations'
|
||||
|
||||
type OperationsPopoverProps = {
|
||||
dataset: DataSet
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: (include?: boolean) => void
|
||||
detectIsUsedByApp: () => void
|
||||
}
|
||||
|
||||
const OperationsPopover = ({
|
||||
dataset,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
}: OperationsPopoverProps) => (
|
||||
<div className="absolute right-2 top-2 z-[15] hidden group-hover:block">
|
||||
<CustomPopover
|
||||
htmlContent={(
|
||||
<Operations
|
||||
showDelete={!isCurrentWorkspaceDatasetOperator}
|
||||
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
)}
|
||||
className="z-20 min-w-[186px]"
|
||||
popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[186px]"
|
||||
position="br"
|
||||
trigger="click"
|
||||
btnElement={(
|
||||
<div className="flex size-8 items-center justify-center rounded-[10px] hover:bg-state-base-hover">
|
||||
<RiMoreFill className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
btnClassName={open =>
|
||||
cn(
|
||||
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border',
|
||||
open ? 'border-components-actionbar-border bg-state-base-hover' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default React.memo(OperationsPopover)
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import * as React from 'react'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type TagAreaProps = {
|
||||
dataset: DataSet
|
||||
tags: Tag[]
|
||||
setTags: (tags: Tag[]) => void
|
||||
onSuccess?: () => void
|
||||
isHoveringTagSelector: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
const TagArea = React.forwardRef<HTMLDivElement, TagAreaProps>(({
|
||||
dataset,
|
||||
tags,
|
||||
setTags,
|
||||
onSuccess,
|
||||
isHoveringTagSelector,
|
||||
onClick,
|
||||
}, ref) => (
|
||||
<div
|
||||
className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'invisible w-full group-hover:visible',
|
||||
tags.length > 0 && 'visible',
|
||||
)}
|
||||
>
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="knowledge"
|
||||
targetID={dataset.id}
|
||||
value={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onCacheUpdate={setTags}
|
||||
onChange={onSuccess}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 z-[5] h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
|
||||
isHoveringTagSelector && 'hidden',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
TagArea.displayName = 'TagArea'
|
||||
|
||||
export default TagArea
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useCheckDatasetUsage, useDeleteDataset } from '@/service/use-dataset-card'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
|
||||
type ModalState = {
|
||||
showRenameModal: boolean
|
||||
showConfirmDelete: boolean
|
||||
confirmMessage: string
|
||||
}
|
||||
|
||||
type UseDatasetCardStateOptions = {
|
||||
dataset: DataSet
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateOptions) => {
|
||||
const { t } = useTranslation()
|
||||
const [tags, setTags] = useState<Tag[]>(dataset.tags)
|
||||
|
||||
useEffect(() => {
|
||||
setTags(dataset.tags)
|
||||
}, [dataset.tags])
|
||||
|
||||
// Modal state
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
})
|
||||
|
||||
// Export state
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
// Modal handlers
|
||||
const openRenameModal = useCallback(() => {
|
||||
setModalState(prev => ({ ...prev, showRenameModal: true }))
|
||||
}, [])
|
||||
|
||||
const closeRenameModal = useCallback(() => {
|
||||
setModalState(prev => ({ ...prev, showRenameModal: false }))
|
||||
}, [])
|
||||
|
||||
const closeConfirmDelete = useCallback(() => {
|
||||
setModalState(prev => ({ ...prev, showConfirmDelete: false }))
|
||||
}, [])
|
||||
|
||||
// API mutations
|
||||
const { mutateAsync: checkUsage } = useCheckDatasetUsage()
|
||||
const { mutateAsync: deleteDatasetMutation } = useDeleteDataset()
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
// Export pipeline handler
|
||||
const handleExportPipeline = useCallback(async (include: boolean = false) => {
|
||||
const { pipeline_id, name } = dataset
|
||||
if (!pipeline_id || exporting)
|
||||
return
|
||||
|
||||
try {
|
||||
setExporting(true)
|
||||
const { data } = await exportPipelineConfig({
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
}
|
||||
finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, exporting, t])
|
||||
|
||||
// Delete flow handlers
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkUsage(dataset.id)
|
||||
const message = isUsedByApp
|
||||
? t('datasetUsedByApp', { ns: 'dataset' })!
|
||||
: t('deleteDatasetConfirmContent', { ns: 'dataset' })!
|
||||
setModalState(prev => ({
|
||||
...prev,
|
||||
confirmMessage: message,
|
||||
showConfirmDelete: true,
|
||||
}))
|
||||
}
|
||||
catch (e: unknown) {
|
||||
if (e instanceof Response) {
|
||||
const res = await e.json()
|
||||
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
|
||||
}
|
||||
else {
|
||||
Toast.notify({ type: 'error', message: (e as Error)?.message || 'Unknown error' })
|
||||
}
|
||||
}
|
||||
}, [dataset.id, checkUsage, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteDatasetMutation(dataset.id)
|
||||
Toast.notify({ type: 'success', message: t('datasetDeleted', { ns: 'dataset' }) })
|
||||
onSuccess?.()
|
||||
}
|
||||
finally {
|
||||
closeConfirmDelete()
|
||||
}
|
||||
}, [dataset.id, deleteDatasetMutation, onSuccess, t, closeConfirmDelete])
|
||||
|
||||
return {
|
||||
// Tag state
|
||||
tags,
|
||||
setTags,
|
||||
|
||||
// Modal state
|
||||
modalState,
|
||||
openRenameModal,
|
||||
closeRenameModal,
|
||||
closeConfirmDelete,
|
||||
|
||||
// Export state
|
||||
exporting,
|
||||
|
||||
// Handlers
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
onConfirmDelete,
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
'use client'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { RiFileTextFill, RiMoreFill, RiRobot2Fill } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CornerLabel from '@/app/components/base/corner-label'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import { DOC_FORM_ICON_WITH_BG, DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import RenameDatasetModal from '../../rename-modal'
|
||||
import Operations from './operations'
|
||||
import CornerLabels from './components/corner-labels'
|
||||
import DatasetCardFooter from './components/dataset-card-footer'
|
||||
import DatasetCardHeader from './components/dataset-card-header'
|
||||
import DatasetCardModals from './components/dataset-card-modals'
|
||||
import Description from './components/description'
|
||||
import OperationsPopover from './components/operations-popover'
|
||||
import TagArea from './components/tag-area'
|
||||
import { useDatasetCardState } from './hooks/use-dataset-card-state'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external'
|
||||
|
||||
@@ -35,320 +24,80 @@ const DatasetCard = ({
|
||||
dataset,
|
||||
onSuccess,
|
||||
}: DatasetCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const [tags, setTags] = useState<Tag[]>(dataset.tags)
|
||||
const tagSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const isHoveringTagSelector = useHover(tagSelectorRef)
|
||||
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [confirmMessage, setConfirmMessage] = useState<string>('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const {
|
||||
tags,
|
||||
setTags,
|
||||
modalState,
|
||||
openRenameModal,
|
||||
closeRenameModal,
|
||||
closeConfirmDelete,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
onConfirmDelete,
|
||||
} = useDatasetCardState({ dataset, onSuccess })
|
||||
|
||||
const isExternalProvider = useMemo(() => {
|
||||
return dataset.provider === EXTERNAL_PROVIDER
|
||||
}, [dataset.provider])
|
||||
const isExternalProvider = dataset.provider === EXTERNAL_PROVIDER
|
||||
const isPipelineUnpublished = useMemo(() => {
|
||||
return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
}, [dataset.runtime_mode, dataset.is_published])
|
||||
const isShowChunkingModeIcon = useMemo(() => {
|
||||
return dataset.doc_form && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
|
||||
}, [dataset.doc_form, dataset.runtime_mode, dataset.is_published])
|
||||
const isShowDocModeInfo = useMemo(() => {
|
||||
return dataset.doc_form && dataset.indexing_technique && dataset.retrieval_model_dict?.search_method && (dataset.runtime_mode !== 'rag_pipeline' || dataset.is_published)
|
||||
}, [dataset.doc_form, dataset.indexing_technique, dataset.retrieval_model_dict?.search_method, dataset.runtime_mode, dataset.is_published])
|
||||
|
||||
const chunkingModeIcon = dataset.doc_form ? DOC_FORM_ICON_WITH_BG[dataset.doc_form] : React.Fragment
|
||||
const Icon = isExternalProvider ? DOC_FORM_ICON_WITH_BG.external : chunkingModeIcon
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (isExternalProvider)
|
||||
push(`/datasets/${dataset.id}/hitTesting`)
|
||||
else if (isPipelineUnpublished)
|
||||
push(`/datasets/${dataset.id}/pipeline`)
|
||||
else
|
||||
push(`/datasets/${dataset.id}/documents`)
|
||||
}
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
const documentCount = useMemo(() => {
|
||||
const availableDocCount = dataset.total_available_documents ?? 0
|
||||
if (availableDocCount === dataset.document_count)
|
||||
return `${dataset.document_count}`
|
||||
if (availableDocCount < dataset.document_count)
|
||||
return `${availableDocCount} / ${dataset.document_count}`
|
||||
}, [dataset.document_count, dataset.total_available_documents])
|
||||
const documentCountTooltip = useMemo(() => {
|
||||
const availableDocCount = dataset.total_available_documents ?? 0
|
||||
if (availableDocCount === dataset.document_count)
|
||||
return t('docAllEnabled', { ns: 'dataset', count: availableDocCount })
|
||||
if (availableDocCount < dataset.document_count)
|
||||
return t('partialEnabled', { ns: 'dataset', count: dataset.document_count, num: availableDocCount })
|
||||
}, [t, dataset.document_count, dataset.total_available_documents])
|
||||
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const editTimeText = useMemo(() => {
|
||||
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`
|
||||
}, [t, dataset.updated_at, formatTimeFromNow])
|
||||
|
||||
const openRenameModal = useCallback(() => {
|
||||
setShowRenameModal(true)
|
||||
}, [])
|
||||
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
const handleExportPipeline = useCallback(async (include = false) => {
|
||||
const { pipeline_id, name } = dataset
|
||||
if (!pipeline_id)
|
||||
return
|
||||
|
||||
if (exporting)
|
||||
return
|
||||
|
||||
try {
|
||||
setExporting(true)
|
||||
const { data } = await exportPipelineConfig({
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('exportFailed', { ns: 'app' }) })
|
||||
}
|
||||
finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, exporting, t])
|
||||
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
|
||||
setConfirmMessage(isUsedByApp ? t('datasetUsedByApp', { ns: 'dataset' })! : t('deleteDatasetConfirmContent', { ns: 'dataset' })!)
|
||||
setShowConfirmDelete(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
const res = await e.json()
|
||||
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
|
||||
}
|
||||
}, [dataset.id, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteDataset(dataset.id)
|
||||
Toast.notify({ type: 'success', message: t('datasetDeleted', { ns: 'dataset' }) })
|
||||
if (onSuccess)
|
||||
onSuccess()
|
||||
}
|
||||
finally {
|
||||
setShowConfirmDelete(false)
|
||||
}
|
||||
}, [dataset.id, onSuccess, t])
|
||||
|
||||
useEffect(() => {
|
||||
setTags(dataset.tags)
|
||||
}, [dataset])
|
||||
const handleTagAreaClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group relative col-span-1 flex h-[190px] cursor-pointer flex-col rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-all duration-200 ease-in-out hover:bg-components-card-bg-alt hover:shadow-md hover:shadow-shadow-shadow-5"
|
||||
data-disable-nprogress={true}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (isExternalProvider)
|
||||
push(`/datasets/${dataset.id}/hitTesting`)
|
||||
else if (isPipelineUnpublished)
|
||||
push(`/datasets/${dataset.id}/pipeline`)
|
||||
else
|
||||
push(`/datasets/${dataset.id}/documents`)
|
||||
}}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{!dataset.embedding_available && (
|
||||
<CornerLabel
|
||||
label={t('cornerLabel.unavailable', { ns: 'dataset' })}
|
||||
className="absolute right-0 top-0 z-10"
|
||||
labelClassName="rounded-tr-xl"
|
||||
/>
|
||||
)}
|
||||
{dataset.embedding_available && dataset.runtime_mode === 'rag_pipeline' && (
|
||||
<CornerLabel
|
||||
label={t('cornerLabel.pipeline', { ns: 'dataset' })}
|
||||
className="absolute right-0 top-0 z-10"
|
||||
labelClassName="rounded-tr-xl"
|
||||
/>
|
||||
)}
|
||||
<div className={cn('flex items-center gap-x-3 px-4 pb-2 pt-4', !dataset.embedding_available && 'opacity-30')}>
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
|
||||
/>
|
||||
{(isShowChunkingModeIcon || isExternalProvider) && (
|
||||
<div className="absolute -bottom-1 -right-1 z-[5]">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-y-1 overflow-hidden py-px">
|
||||
<div
|
||||
className="system-md-semibold truncate text-text-secondary"
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium leading-[18px] text-text-tertiary">
|
||||
<div className="truncate" title={dataset.author_name}>{dataset.author_name}</div>
|
||||
<div>·</div>
|
||||
<div className="truncate" title={editTimeText}>{editTimeText}</div>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase flex items-center gap-x-3 text-text-tertiary">
|
||||
{isExternalProvider && <span>{t('externalKnowledgeBase', { ns: 'dataset' })}</span>}
|
||||
{!isExternalProvider && isShowDocModeInfo && (
|
||||
<>
|
||||
{dataset.doc_form && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
|
||||
>
|
||||
{t(`chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`, { ns: 'dataset' })}
|
||||
</span>
|
||||
)}
|
||||
{dataset.indexing_technique && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
|
||||
>
|
||||
{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method) as any}
|
||||
</span>
|
||||
)}
|
||||
{dataset.is_multimodal && (
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate"
|
||||
title={t('multimodal', { ns: 'dataset' })}
|
||||
>
|
||||
{t('multimodal', { ns: 'dataset' })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('system-xs-regular line-clamp-2 h-10 px-4 py-1 text-text-tertiary', !dataset.embedding_available && 'opacity-30')}
|
||||
title={dataset.description}
|
||||
>
|
||||
{dataset.description}
|
||||
</div>
|
||||
<div
|
||||
className={cn('relative w-full px-3', !dataset.embedding_available && 'opacity-30')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={tagSelectorRef}
|
||||
className={cn(
|
||||
'invisible w-full group-hover:visible',
|
||||
tags.length > 0 && 'visible',
|
||||
)}
|
||||
>
|
||||
<TagSelector
|
||||
position="bl"
|
||||
type="knowledge"
|
||||
targetID={dataset.id}
|
||||
value={tags.map(tag => tag.id)}
|
||||
selectedTags={tags}
|
||||
onCacheUpdate={setTags}
|
||||
onChange={onSuccess}
|
||||
/>
|
||||
</div>
|
||||
{/* Tag Mask */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 z-[5] h-full w-20 bg-tag-selector-mask-bg group-hover:bg-tag-selector-mask-hover-bg',
|
||||
isHoveringTagSelector && 'hidden',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-3 px-4 pb-3 pt-2 text-text-tertiary',
|
||||
!dataset.embedding_available && 'opacity-30',
|
||||
)}
|
||||
>
|
||||
<Tooltip popupContent={documentCountTooltip}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiFileTextFill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{documentCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!isExternalProvider && (
|
||||
<Tooltip popupContent={`${dataset.app_count} ${t('appCount', { ns: 'dataset' })}`}>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<RiRobot2Fill className="size-3 text-text-quaternary" />
|
||||
<span className="system-xs-medium">{dataset.app_count}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="system-xs-regular text-divider-deep">/</span>
|
||||
<span className="system-xs-regular">{`${t('updated', { ns: 'dataset' })} ${formatTimeFromNow(dataset.updated_at * 1000)}`}</span>
|
||||
</div>
|
||||
<div className="absolute right-2 top-2 z-[15] hidden group-hover:block">
|
||||
<CustomPopover
|
||||
htmlContent={(
|
||||
<Operations
|
||||
showDelete={!isCurrentWorkspaceDatasetOperator}
|
||||
showExportPipeline={dataset.runtime_mode === 'rag_pipeline'}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
)}
|
||||
className="z-20 min-w-[186px]"
|
||||
popupClassName="rounded-xl bg-none shadow-none ring-0 min-w-[186px]"
|
||||
position="br"
|
||||
trigger="click"
|
||||
btnElement={(
|
||||
<div className="flex size-8 items-center justify-center rounded-[10px] hover:bg-state-base-hover">
|
||||
<RiMoreFill className="h-5 w-5 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
btnClassName={open =>
|
||||
cn(
|
||||
'size-9 cursor-pointer justify-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0 shadow-lg shadow-shadow-shadow-5 ring-[2px] ring-inset ring-components-actionbar-bg hover:border-components-actionbar-border',
|
||||
open ? 'border-components-actionbar-border bg-state-base-hover' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showRenameModal && (
|
||||
<RenameDatasetModal
|
||||
show={showRenameModal}
|
||||
<CornerLabels dataset={dataset} />
|
||||
<DatasetCardHeader dataset={dataset} />
|
||||
<Description dataset={dataset} />
|
||||
<TagArea
|
||||
ref={tagSelectorRef}
|
||||
dataset={dataset}
|
||||
onClose={() => setShowRenameModal(false)}
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
onSuccess={onSuccess}
|
||||
isHoveringTagSelector={isHoveringTagSelector}
|
||||
onClick={handleTagAreaClick}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('deleteDatasetConfirmTitle', { ns: 'dataset' })}
|
||||
content={confirmMessage}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
<DatasetCardFooter dataset={dataset} />
|
||||
<OperationsPopover
|
||||
dataset={dataset}
|
||||
isCurrentWorkspaceDatasetOperator={isCurrentWorkspaceDatasetOperator}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DatasetCardModals
|
||||
dataset={dataset}
|
||||
modalState={modalState}
|
||||
onCloseRename={closeRenameModal}
|
||||
onCloseConfirm={closeConfirmDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import {
|
||||
Agent,
|
||||
Answer,
|
||||
@@ -55,7 +54,6 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
|
||||
[BlockEnum.TemplateTransform]: TemplatingTransform,
|
||||
[BlockEnum.VariableAssigner]: VariableX,
|
||||
[BlockEnum.VariableAggregator]: VariableX,
|
||||
[BlockEnum.Group]: FolderLine,
|
||||
[BlockEnum.Assigner]: Assigner,
|
||||
[BlockEnum.Tool]: VariableX,
|
||||
[BlockEnum.IterationStart]: VariableX,
|
||||
@@ -99,7 +97,6 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
||||
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Group]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
} from './hooks'
|
||||
import { BlockEnum, NodeRunningStatus } from './types'
|
||||
import { NodeRunningStatus } from './types'
|
||||
import { getEdgeColor } from './utils'
|
||||
|
||||
const CustomEdge = ({
|
||||
@@ -136,7 +136,7 @@ const CustomEdge = ({
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
|
||||
strokeDasharray: (data._isTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined,
|
||||
strokeDasharray: data._isTemp ? '8 8' : undefined,
|
||||
}}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const CUSTOM_GROUP_NODE = 'custom-group'
|
||||
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
|
||||
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
|
||||
|
||||
export const GROUP_CHILDREN_Z_INDEX = 1002
|
||||
|
||||
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
|
||||
CUSTOM_GROUP_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
])
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupExitPortNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupExitPortNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupExitPortNodeData
|
||||
}
|
||||
|
||||
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-green-green-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives internal connections from leaf nodes */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to external nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupExitPortNode)
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupInputNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupInputNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupInputNodeData
|
||||
}
|
||||
|
||||
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-blue-blue-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives external connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to entry nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupInputNode)
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupNodeData
|
||||
}
|
||||
|
||||
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ data }) => {
|
||||
const { group } = data
|
||||
const exitPorts = group.exitPorts ?? []
|
||||
const connectedSourceHandleIds = data._connectedSourceHandleIds ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-workflow-block-parma-bg/50 group relative rounded-2xl border-2 border-dashed border-components-panel-border',
|
||||
data.selected && 'border-primary-400',
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 280,
|
||||
height: data.height || 200,
|
||||
}}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{group.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Target handle for incoming connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className={cn(
|
||||
'!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
)}
|
||||
style={{ top: '50%' }}
|
||||
/>
|
||||
|
||||
<div className="px-3 pt-3">
|
||||
{exitPorts.map((port, index) => {
|
||||
const connected = connectedSourceHandleIds.includes(port.portNodeId)
|
||||
|
||||
return (
|
||||
<div key={port.portNodeId} className="relative flex h-6 items-center px-1">
|
||||
<div className="w-full text-right text-xs font-semibold text-text-secondary">
|
||||
{port.name}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id={port.portNodeId}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className={cn(
|
||||
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
!connected && 'after:opacity-0',
|
||||
'!-right-[21px] !top-1/2 !-translate-y-1/2',
|
||||
)}
|
||||
isConnectable
|
||||
/>
|
||||
|
||||
{/* Visual "+" indicator (styling aligned with existing branch handles) */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-10 hidden h-4 w-4 items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface',
|
||||
'-right-[21px] top-1/2 -translate-y-1/2',
|
||||
'group-hover:flex',
|
||||
data.selected && '!flex',
|
||||
)}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupNode)
|
||||
@@ -1,19 +0,0 @@
|
||||
export {
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_NODE,
|
||||
GROUP_CHILDREN_Z_INDEX,
|
||||
UI_ONLY_GROUP_NODE_TYPES,
|
||||
} from './constants'
|
||||
|
||||
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'
|
||||
|
||||
export { default as CustomGroupInputNode } from './custom-group-input-node'
|
||||
export { default as CustomGroupNode } from './custom-group-node'
|
||||
export type {
|
||||
CustomGroupExitPortNodeData,
|
||||
CustomGroupInputNodeData,
|
||||
CustomGroupNodeData,
|
||||
ExitPortInfo,
|
||||
GroupMember,
|
||||
} from './types'
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { BlockEnum } from '../types'
|
||||
|
||||
/**
|
||||
* Exit port info stored in Group node
|
||||
*/
|
||||
export type ExitPortInfo = {
|
||||
portNodeId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Group node data structure
|
||||
* node.type = 'custom-group'
|
||||
* node.data.type = '' (empty string to bypass backend NodeType validation)
|
||||
*/
|
||||
export type CustomGroupNodeData = {
|
||||
type: '' // Empty string bypasses backend NodeType validation
|
||||
title: string
|
||||
desc?: string
|
||||
_connectedSourceHandleIds?: string[]
|
||||
_connectedTargetHandleIds?: string[]
|
||||
group: {
|
||||
groupId: string
|
||||
title: string
|
||||
memberNodeIds: string[]
|
||||
entryNodeIds: string[]
|
||||
inputNodeId: string
|
||||
exitPorts: ExitPortInfo[]
|
||||
collapsed: boolean
|
||||
}
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Input node data structure
|
||||
* node.type = 'custom-group-input'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupInputNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
groupInput: {
|
||||
groupId: string
|
||||
title: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit Port node data structure
|
||||
* node.type = 'custom-group-exit-port'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupExitPortNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
exitPort: {
|
||||
groupId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Member node info for display
|
||||
*/
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
type: BlockEnum
|
||||
label?: string
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { useCallback } from 'react'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
@@ -109,50 +108,6 @@ export const useEdgesInteractions = () => {
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
|
||||
// collect edges to delete (including corresponding real edges for temp edges)
|
||||
const edgesToDelete: Set<string> = new Set([currentEdge.id])
|
||||
|
||||
// if deleting a temp edge connected to a group, also delete the corresponding real hidden edge
|
||||
if (currentEdge.data?._isTemp) {
|
||||
const groupNode = nodes.find(n =>
|
||||
n.data.type === BlockEnum.Group
|
||||
&& (n.id === currentEdge.source || n.id === currentEdge.target),
|
||||
)
|
||||
|
||||
if (groupNode) {
|
||||
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
|
||||
|
||||
if (currentEdge.target === groupNode.id) {
|
||||
// inbound temp edge: find real edge with same source, target is a head node
|
||||
edges.forEach((edge) => {
|
||||
if (edge.source === currentEdge.source
|
||||
&& memberIds.has(edge.target)
|
||||
&& edge.sourceHandle === currentEdge.sourceHandle) {
|
||||
edgesToDelete.add(edge.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (currentEdge.source === groupNode.id) {
|
||||
// outbound temp edge: sourceHandle format is "leafNodeId-originalHandle"
|
||||
const sourceHandle = currentEdge.sourceHandle || ''
|
||||
const lastDashIndex = sourceHandle.lastIndexOf('-')
|
||||
if (lastDashIndex > 0) {
|
||||
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
|
||||
const originalHandle = sourceHandle.substring(lastDashIndex + 1)
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (edge.source === leafNodeId
|
||||
&& edge.target === currentEdge.target
|
||||
&& (edge.sourceHandle || 'source') === originalHandle) {
|
||||
edgesToDelete.add(edge.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
@@ -171,10 +126,7 @@ export const useEdgesInteractions = () => {
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
for (let i = draft.length - 1; i >= 0; i--) {
|
||||
if (edgesToDelete.has(draft[i].id))
|
||||
draft.splice(i, 1)
|
||||
}
|
||||
draft.splice(currentEdgeIndex, 1)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { PredecessorHandle } from '../utils'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore as useReactFlowStore } from 'reactflow'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { getCommonPredecessorHandles } from '../utils'
|
||||
|
||||
export type MakeGroupAvailability = {
|
||||
canMakeGroup: boolean
|
||||
branchEntryNodeIds: string[]
|
||||
commonPredecessorHandle?: PredecessorHandle
|
||||
}
|
||||
|
||||
type MinimalEdge = {
|
||||
id: string
|
||||
source: string
|
||||
sourceHandle: string
|
||||
target: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to check if the selected nodes can be grouped.
|
||||
* Can be called both from React hooks and imperatively.
|
||||
*/
|
||||
export const checkMakeGroupAvailability = (
|
||||
selectedNodeIds: string[],
|
||||
edges: MinimalEdge[],
|
||||
hasGroupNode = false,
|
||||
): MakeGroupAvailability => {
|
||||
if (selectedNodeIds.length <= 1 || hasGroupNode) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds: [],
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNodeIdSet = new Set(selectedNodeIds)
|
||||
const inboundFromOutsideTargets = new Set<string>()
|
||||
const incomingEdgeCounts = new Map<string, number>()
|
||||
const incomingFromSelectedTargets = new Set<string>()
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// Only consider edges whose target is inside the selected subgraph.
|
||||
if (!selectedNodeIdSet.has(edge.target))
|
||||
return
|
||||
|
||||
incomingEdgeCounts.set(edge.target, (incomingEdgeCounts.get(edge.target) ?? 0) + 1)
|
||||
|
||||
if (selectedNodeIdSet.has(edge.source))
|
||||
incomingFromSelectedTargets.add(edge.target)
|
||||
else
|
||||
inboundFromOutsideTargets.add(edge.target)
|
||||
})
|
||||
|
||||
// Branch head (entry) definition:
|
||||
// - has at least one incoming edge
|
||||
// - and all its incoming edges come from outside the selected subgraph
|
||||
const branchEntryNodeIds = selectedNodeIds.filter((nodeId) => {
|
||||
const incomingEdgeCount = incomingEdgeCounts.get(nodeId) ?? 0
|
||||
if (incomingEdgeCount === 0)
|
||||
return false
|
||||
|
||||
return !incomingFromSelectedTargets.has(nodeId)
|
||||
})
|
||||
|
||||
// No branch head means we cannot tell how many branches are represented by this selection.
|
||||
if (branchEntryNodeIds.length === 0) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Guardrail: disallow side entrances into the selected subgraph.
|
||||
// If an outside node connects to a non-entry node inside the selection, the grouping boundary is ambiguous.
|
||||
const branchEntryNodeIdSet = new Set(branchEntryNodeIds)
|
||||
const hasInboundToNonEntryNode = Array.from(inboundFromOutsideTargets).some(nodeId => !branchEntryNodeIdSet.has(nodeId))
|
||||
|
||||
if (hasInboundToNonEntryNode) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the branch heads by their common predecessor "handler" (source node + sourceHandle).
|
||||
// This is required for multi-handle nodes like If-Else / Classifier where different branches use different handles.
|
||||
const commonPredecessorHandles = getCommonPredecessorHandles(
|
||||
branchEntryNodeIds,
|
||||
// Only look at edges coming from outside the selected subgraph when determining the "pre" handler.
|
||||
edges.filter(edge => !selectedNodeIdSet.has(edge.source)),
|
||||
)
|
||||
|
||||
if (commonPredecessorHandles.length !== 1) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canMakeGroup: true,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: commonPredecessorHandles[0],
|
||||
}
|
||||
}
|
||||
|
||||
export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => {
|
||||
const edgeKeys = useReactFlowStore((state) => {
|
||||
const delimiter = '\u0000'
|
||||
const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`)
|
||||
keys.sort()
|
||||
return keys
|
||||
}, shallow)
|
||||
|
||||
const hasGroupNode = useReactFlowStore((state) => {
|
||||
return state.getNodes().some(node => node.selected && node.data.type === BlockEnum.Group)
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const delimiter = '\u0000'
|
||||
const edges = edgeKeys.map((key) => {
|
||||
const [source, handleId, target] = key.split(delimiter)
|
||||
return {
|
||||
id: key,
|
||||
source,
|
||||
sourceHandle: handleId || 'source',
|
||||
target,
|
||||
}
|
||||
})
|
||||
|
||||
return checkMakeGroupAvailability(selectedNodeIds, edges, hasGroupNode)
|
||||
}, [edgeKeys, selectedNodeIds, hasGroupNode])
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ResizeParamsWithDirection,
|
||||
} from 'reactflow'
|
||||
import type { PluginDefaultValue } from '../block-selector/types'
|
||||
import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
|
||||
@@ -53,7 +52,6 @@ import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
|
||||
import { useHelpline } from './use-helpline'
|
||||
import useInspectVarsCrud from './use-inspect-vars-crud'
|
||||
import { checkMakeGroupAvailability } from './use-make-group'
|
||||
import { useNodesMetaData } from './use-nodes-meta-data'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import {
|
||||
@@ -75,151 +73,6 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
|
||||
y: 21, // Adjusted based on visual testing feedback
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse group handler id to get original node id and sourceHandle
|
||||
* Handler id format: `${nodeId}-${sourceHandle}`
|
||||
*/
|
||||
function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } {
|
||||
const lastDashIndex = handlerId.lastIndexOf('-')
|
||||
return {
|
||||
originalNodeId: handlerId.substring(0, lastDashIndex),
|
||||
originalSourceHandle: handlerId.substring(lastDashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pair of edges for group node connections:
|
||||
* - realEdge: hidden edge from original node to target (persisted to backend)
|
||||
* - uiEdge: visible temp edge from group to target (UI-only, not persisted)
|
||||
*/
|
||||
function createGroupEdgePair(params: {
|
||||
groupNodeId: string
|
||||
handlerId: string
|
||||
targetNodeId: string
|
||||
targetHandle: string
|
||||
nodes: Node[]
|
||||
baseEdgeData?: Partial<Edge['data']>
|
||||
zIndex?: number
|
||||
}): { realEdge: Edge, uiEdge: Edge } | null {
|
||||
const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params
|
||||
|
||||
const groupNode = nodes.find(node => node.id === groupNodeId)
|
||||
const groupData = groupNode?.data as GroupNodeData | undefined
|
||||
const handler = groupData?.handlers?.find(h => h.id === handlerId)
|
||||
|
||||
let originalNodeId: string
|
||||
let originalSourceHandle: string
|
||||
|
||||
if (handler?.nodeId && handler?.sourceHandle) {
|
||||
originalNodeId = handler.nodeId
|
||||
originalSourceHandle = handler.sourceHandle
|
||||
}
|
||||
else {
|
||||
const parsed = parseGroupHandlerId(handlerId)
|
||||
originalNodeId = parsed.originalNodeId
|
||||
originalSourceHandle = parsed.originalSourceHandle
|
||||
}
|
||||
|
||||
const originalNode = nodes.find(node => node.id === originalNodeId)
|
||||
const targetNode = nodes.find(node => node.id === targetNodeId)
|
||||
|
||||
if (!originalNode || !targetNode)
|
||||
return null
|
||||
|
||||
// Create the real edge (from original node to target) - hidden because original node is in group
|
||||
const realEdge: Edge = {
|
||||
id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: originalNodeId,
|
||||
sourceHandle: originalSourceHandle,
|
||||
target: targetNodeId,
|
||||
targetHandle,
|
||||
hidden: true,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: originalNode.data.type,
|
||||
targetType: targetNode.data.type,
|
||||
_hiddenInGroupId: groupNodeId,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
// Create the UI edge (from group to target) - temporary, not persisted to backend
|
||||
const uiEdge: Edge = {
|
||||
id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: groupNodeId,
|
||||
sourceHandle: handlerId,
|
||||
target: targetNodeId,
|
||||
targetHandle,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: targetNode.data.type,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
return { realEdge, uiEdge }
|
||||
}
|
||||
|
||||
function createGroupInboundEdges(params: {
|
||||
sourceNodeId: string
|
||||
sourceHandle: string
|
||||
groupNodeId: string
|
||||
groupData: GroupNodeData
|
||||
nodes: Node[]
|
||||
baseEdgeData?: Partial<Edge['data']>
|
||||
zIndex?: number
|
||||
}): { realEdges: Edge[], uiEdge: Edge } | null {
|
||||
const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params
|
||||
|
||||
const sourceNode = nodes.find(node => node.id === sourceNodeId)
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
if (!sourceNode || headNodeIds.length === 0)
|
||||
return null
|
||||
|
||||
const realEdges: Edge[] = headNodeIds.map((headNodeId) => {
|
||||
const headNode = nodes.find(node => node.id === headNodeId)
|
||||
return {
|
||||
id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: sourceNodeId,
|
||||
sourceHandle,
|
||||
target: headNodeId,
|
||||
targetHandle: 'target',
|
||||
hidden: true,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: sourceNode.data.type,
|
||||
targetType: headNode?.data.type,
|
||||
_hiddenInGroupId: groupNodeId,
|
||||
},
|
||||
zIndex,
|
||||
} as Edge
|
||||
})
|
||||
|
||||
const uiEdge: Edge = {
|
||||
id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: sourceNodeId,
|
||||
sourceHandle,
|
||||
target: groupNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: sourceNode.data.type,
|
||||
targetType: BlockEnum.Group,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
return { realEdges, uiEdge }
|
||||
}
|
||||
|
||||
export const useNodesInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
@@ -595,146 +448,6 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if source is a group node - need special handling
|
||||
const isSourceGroup = sourceNode?.data.type === BlockEnum.Group
|
||||
|
||||
if (isSourceGroup && sourceHandle && target && targetHandle) {
|
||||
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle)
|
||||
|
||||
// Check if real edge already exists
|
||||
if (edges.find(edge =>
|
||||
edge.source === originalNodeId
|
||||
&& edge.sourceHandle === originalSourceHandle
|
||||
&& edge.target === target
|
||||
&& edge.targetHandle === targetHandle,
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentNode = nodes.find(node => node.id === targetNode?.parentId)
|
||||
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: source!,
|
||||
handlerId: sourceHandle,
|
||||
targetNodeId: target,
|
||||
targetHandle,
|
||||
nodes,
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? targetNode?.parentId : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? targetNode?.parentId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!edgePair)
|
||||
return
|
||||
|
||||
const { realEdge, uiEdge } = edgePair
|
||||
|
||||
// Update connected handle ids for the original node
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge: realEdge }],
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.push(realEdge)
|
||||
draft.push(uiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
|
||||
nodeId: targetNode?.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isTargetGroup = targetNode?.data.type === BlockEnum.Group
|
||||
|
||||
if (isTargetGroup && source && sourceHandle) {
|
||||
const groupData = targetNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
if (edges.find(edge =>
|
||||
edge.source === source
|
||||
&& edge.sourceHandle === sourceHandle
|
||||
&& edge.target === target
|
||||
&& edge.targetHandle === targetHandle,
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentNode = nodes.find(node => node.id === sourceNode?.parentId)
|
||||
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const inboundResult = createGroupInboundEdges({
|
||||
sourceNodeId: source,
|
||||
sourceHandle,
|
||||
groupNodeId: target!,
|
||||
groupData,
|
||||
nodes,
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? sourceNode?.parentId : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? sourceNode?.parentId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!inboundResult)
|
||||
return
|
||||
|
||||
const { realEdges, uiEdge } = inboundResult
|
||||
|
||||
const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge }))
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes)
|
||||
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
realEdges.forEach((edge) => {
|
||||
draft.push(edge)
|
||||
})
|
||||
draft.push(uiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
|
||||
nodeId: headNodeIds[0],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
edges.find(
|
||||
edge =>
|
||||
@@ -1196,34 +909,8 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if prevNode is a group node - need special handling
|
||||
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
|
||||
let newEdge: Edge | null = null
|
||||
let newUiEdge: Edge | null = null
|
||||
|
||||
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: prevNodeId,
|
||||
handlerId: prevNodeSourceHandle,
|
||||
targetNodeId: newNode.id,
|
||||
targetHandle,
|
||||
nodes: [...nodes, newNode],
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (edgePair) {
|
||||
newEdge = edgePair.realEdge
|
||||
newUiEdge = edgePair.uiEdge
|
||||
}
|
||||
}
|
||||
else if (nodeType !== BlockEnum.DataSource) {
|
||||
// Normal case: prevNode is not a group
|
||||
let newEdge = null
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
@@ -1248,10 +935,9 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! }))
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgesToAdd,
|
||||
(newEdge ? [{ type: 'add', edge: newEdge }] : []),
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
@@ -1320,8 +1006,6 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
if (newEdge)
|
||||
draft.push(newEdge)
|
||||
if (newUiEdge)
|
||||
draft.push(newUiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
@@ -1406,7 +1090,7 @@ export const useNodesInteractions = () => {
|
||||
|
||||
const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!)
|
||||
const afterNodesInSameBranchIds = afterNodesInSameBranch.map(
|
||||
(node: Node) => node.id,
|
||||
node => node.id,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
@@ -1516,113 +1200,37 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if prevNode is a group node - need special handling
|
||||
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
|
||||
let newPrevEdge: Edge | null = null
|
||||
let newPrevUiEdge: Edge | null = null
|
||||
const edgesToRemove: string[] = []
|
||||
const currentEdgeIndex = edges.findIndex(
|
||||
edge => edge.source === prevNodeId && edge.target === nextNodeId,
|
||||
)
|
||||
let newPrevEdge = null
|
||||
|
||||
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
|
||||
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle)
|
||||
|
||||
// Find edges to remove: both hidden real edge and UI temp edge from group to nextNode
|
||||
const hiddenEdge = edges.find(
|
||||
edge => edge.source === originalNodeId
|
||||
&& edge.sourceHandle === originalSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
const uiTempEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
if (hiddenEdge)
|
||||
edgesToRemove.push(hiddenEdge.id)
|
||||
if (uiTempEdge)
|
||||
edgesToRemove.push(uiTempEdge.id)
|
||||
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: prevNodeId,
|
||||
handlerId: prevNodeSourceHandle,
|
||||
targetNodeId: newNode.id,
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newPrevEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: prevNodeId,
|
||||
sourceHandle: prevNodeSourceHandle,
|
||||
target: newNode.id,
|
||||
targetHandle,
|
||||
nodes: [...nodes, newNode],
|
||||
baseEdgeData: {
|
||||
data: {
|
||||
sourceType: prevNode.data.type,
|
||||
targetType: newNode.data.type,
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (edgePair) {
|
||||
newPrevEdge = edgePair.realEdge
|
||||
newPrevUiEdge = edgePair.uiEdge
|
||||
}
|
||||
}
|
||||
else {
|
||||
const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group
|
||||
|
||||
if (isNextNodeGroupForRemoval) {
|
||||
const groupData = nextNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
headNodeIds.forEach((headNodeId) => {
|
||||
const realEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === headNodeId,
|
||||
)
|
||||
if (realEdge)
|
||||
edgesToRemove.push(realEdge.id)
|
||||
})
|
||||
|
||||
const uiEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
if (uiEdge)
|
||||
edgesToRemove.push(uiEdge.id)
|
||||
}
|
||||
else {
|
||||
const currentEdge = edges.find(
|
||||
edge => edge.source === prevNodeId && edge.target === nextNodeId,
|
||||
)
|
||||
if (currentEdge)
|
||||
edgesToRemove.push(currentEdge.id)
|
||||
}
|
||||
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newPrevEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: prevNodeId,
|
||||
sourceHandle: prevNodeSourceHandle,
|
||||
target: newNode.id,
|
||||
targetHandle,
|
||||
data: {
|
||||
sourceType: prevNode.data.type,
|
||||
targetType: newNode.data.type,
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: prevNode.parentId
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
zIndex: prevNode.parentId
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let newNextEdge: Edge | null = null
|
||||
let newNextUiEdge: Edge | null = null
|
||||
const newNextRealEdges: Edge[] = []
|
||||
|
||||
const nextNodeParentNode
|
||||
= nodes.find(node => node.id === nextNode.parentId) || null
|
||||
@@ -1633,113 +1241,49 @@ export const useNodesInteractions = () => {
|
||||
= !!nextNodeParentNode
|
||||
&& nextNodeParentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const isNextNodeGroup = nextNode.data.type === BlockEnum.Group
|
||||
|
||||
if (
|
||||
nodeType !== BlockEnum.IfElse
|
||||
&& nodeType !== BlockEnum.QuestionClassifier
|
||||
&& nodeType !== BlockEnum.LoopEnd
|
||||
) {
|
||||
if (isNextNodeGroup) {
|
||||
const groupData = nextNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
headNodeIds.forEach((headNodeId) => {
|
||||
const headNode = nodes.find(node => node.id === headNodeId)
|
||||
newNextRealEdges.push({
|
||||
id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: headNodeId,
|
||||
targetHandle: 'target',
|
||||
hidden: true,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: headNode?.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_hiddenInGroupId: nextNodeId,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
} as Edge)
|
||||
})
|
||||
|
||||
newNextUiEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: BlockEnum.Group,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_isTemp: true,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
else {
|
||||
newNextEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: nextNodeTargetHandle,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: nextNode.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration
|
||||
? nextNode.parentId
|
||||
: undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
newNextEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: nextNodeTargetHandle,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: nextNode.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration
|
||||
? nextNode.parentId
|
||||
: undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
const edgeChanges = [
|
||||
...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge),
|
||||
...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []),
|
||||
...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []),
|
||||
...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []),
|
||||
...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })),
|
||||
...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []),
|
||||
]
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgeChanges,
|
||||
[
|
||||
{ type: 'remove', edge: edges[currentEdgeIndex] },
|
||||
...(newPrevEdge ? [{ type: 'add', edge: newPrevEdge }] : []),
|
||||
...(newNextEdge ? [{ type: 'add', edge: newNextEdge }] : []),
|
||||
],
|
||||
[...nodes, newNode],
|
||||
)
|
||||
|
||||
const afterNodesInSameBranch = getAfterNodesInSameBranch(nextNodeId!)
|
||||
const afterNodesInSameBranchIds = afterNodesInSameBranch.map(
|
||||
(node: Node) => node.id,
|
||||
node => node.id,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
@@ -1798,10 +1342,7 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
}
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const filteredDraft = draft.filter(edge => !edgesToRemove.includes(edge.id))
|
||||
draft.length = 0
|
||||
draft.push(...filteredDraft)
|
||||
|
||||
draft.splice(currentEdgeIndex, 1)
|
||||
draft.forEach((item) => {
|
||||
item.data = {
|
||||
...item.data,
|
||||
@@ -1810,15 +1351,9 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
if (newPrevEdge)
|
||||
draft.push(newPrevEdge)
|
||||
if (newPrevUiEdge)
|
||||
draft.push(newPrevUiEdge)
|
||||
|
||||
if (newNextEdge)
|
||||
draft.push(newNextEdge)
|
||||
newNextRealEdges.forEach((edge) => {
|
||||
draft.push(edge)
|
||||
})
|
||||
if (newNextUiEdge)
|
||||
draft.push(newNextUiEdge)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}
|
||||
@@ -2543,302 +2078,6 @@ export const useNodesInteractions = () => {
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
|
||||
// Check if there are any nodes selected via box selection
|
||||
const hasBundledNodes = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
return nodes.some(node => node.data._isBundled)
|
||||
}, [store])
|
||||
|
||||
const getCanMakeGroup = useCallback(() => {
|
||||
const { getNodes, edges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
return false
|
||||
|
||||
const bundledNodeIds = bundledNodes.map(node => node.id)
|
||||
const minimalEdges = edges.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle || 'source',
|
||||
target: edge.target,
|
||||
}))
|
||||
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
|
||||
|
||||
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
|
||||
return canMakeGroup
|
||||
}, [store])
|
||||
|
||||
const handleMakeGroup = useCallback(() => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
return
|
||||
|
||||
const bundledNodeIds = bundledNodes.map(node => node.id)
|
||||
const minimalEdges = edges.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle || 'source',
|
||||
target: edge.target,
|
||||
}))
|
||||
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
|
||||
|
||||
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
|
||||
if (!canMakeGroup)
|
||||
return
|
||||
|
||||
const bundledNodeIdSet = new Set(bundledNodeIds)
|
||||
const bundledNodeIdIsLeaf = new Set<string>()
|
||||
const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target))
|
||||
const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target))
|
||||
|
||||
// leaf node: no outbound edges to other nodes in the selection
|
||||
const handlers: GroupHandler[] = []
|
||||
const leafNodeIdSet = new Set<string>()
|
||||
|
||||
bundledNodes.forEach((node: Node) => {
|
||||
const targetBranches = node.data._targetBranches || [{ id: 'source', name: node.data.title }]
|
||||
targetBranches.forEach((branch) => {
|
||||
// A branch should be a handler if it's either:
|
||||
// 1. Connected to a node OUTSIDE the group
|
||||
// 2. NOT connected to any node INSIDE the group
|
||||
const isConnectedInside = edges.some(edge =>
|
||||
edge.source === node.id
|
||||
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
|
||||
&& bundledNodeIdSet.has(edge.target),
|
||||
)
|
||||
const isConnectedOutside = edges.some(edge =>
|
||||
edge.source === node.id
|
||||
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
|
||||
&& !bundledNodeIdSet.has(edge.target),
|
||||
)
|
||||
|
||||
if (isConnectedOutside || !isConnectedInside) {
|
||||
const handlerId = `${node.id}-${branch.id}`
|
||||
handlers.push({
|
||||
id: handlerId,
|
||||
label: branch.name || node.data.title || node.id,
|
||||
nodeId: node.id,
|
||||
sourceHandle: branch.id,
|
||||
})
|
||||
leafNodeIdSet.add(node.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const leafNodeIds = Array.from(leafNodeIdSet)
|
||||
leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id))
|
||||
|
||||
const members: GroupMember[] = bundledNodes.map((node) => {
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
label: node.data.title,
|
||||
}
|
||||
})
|
||||
|
||||
// head nodes: nodes that receive input from outside the group
|
||||
const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))]
|
||||
|
||||
// put the group node at the top-left corner of the selection, slightly offset
|
||||
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
|
||||
|
||||
const groupNodeData: GroupNodeData = {
|
||||
title: t('operator.makeGroup', { ns: 'workflow' }),
|
||||
desc: '',
|
||||
type: BlockEnum.Group,
|
||||
members,
|
||||
handlers,
|
||||
headNodeIds,
|
||||
leafNodeIds,
|
||||
selected: true,
|
||||
_targetBranches: handlers.map(handler => ({
|
||||
id: handler.id,
|
||||
name: handler.label || handler.id,
|
||||
})),
|
||||
}
|
||||
|
||||
const { newNode: groupNode } = generateNewNode({
|
||||
data: groupNodeData,
|
||||
position: {
|
||||
x: minX - 20,
|
||||
y: minY - 20,
|
||||
},
|
||||
})
|
||||
|
||||
const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type]))
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (bundledNodeIdSet.has(node.id)) {
|
||||
node.data._isBundled = false
|
||||
node.selected = false
|
||||
node.hidden = true
|
||||
node.data._hiddenInGroupId = groupNode.id
|
||||
}
|
||||
else {
|
||||
node.data._isBundled = false
|
||||
}
|
||||
})
|
||||
draft.push(groupNode)
|
||||
})
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) {
|
||||
edge.hidden = true
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
_hiddenInGroupId: groupNode.id,
|
||||
_isBundled: false,
|
||||
}
|
||||
}
|
||||
else if (edge.data?._isBundled) {
|
||||
edge.data._isBundled = false
|
||||
}
|
||||
})
|
||||
|
||||
// re-add the external inbound edges to the group node as UI-only edges (not persisted to backend)
|
||||
inboundEdges.forEach((edge) => {
|
||||
draft.push({
|
||||
id: `${edge.id}__to-${groupNode.id}`,
|
||||
type: edge.type || CUSTOM_EDGE,
|
||||
source: edge.source,
|
||||
target: groupNode.id,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
...edge.data,
|
||||
sourceType: nodeTypeMap.get(edge.source)!,
|
||||
targetType: BlockEnum.Group,
|
||||
_hiddenInGroupId: undefined,
|
||||
_isBundled: false,
|
||||
_isTemp: true, // UI-only edge, not persisted to backend
|
||||
},
|
||||
zIndex: edge.zIndex,
|
||||
})
|
||||
})
|
||||
|
||||
// outbound edges of the group node as UI-only edges (not persisted to backend)
|
||||
outboundEdges.forEach((edge) => {
|
||||
if (!bundledNodeIdIsLeaf.has(edge.source))
|
||||
return
|
||||
|
||||
// Use the same handler id format: nodeId-sourceHandle
|
||||
const originalSourceHandle = edge.sourceHandle || 'source'
|
||||
const handlerId = `${edge.source}-${originalSourceHandle}`
|
||||
|
||||
draft.push({
|
||||
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`,
|
||||
type: edge.type || CUSTOM_EDGE,
|
||||
source: groupNode.id,
|
||||
target: edge.target,
|
||||
sourceHandle: handlerId,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: {
|
||||
...edge.data,
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: nodeTypeMap.get(edge.target)!,
|
||||
_hiddenInGroupId: undefined,
|
||||
_isBundled: false,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex: edge.zIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
workflowStore.setState({
|
||||
selectionMenu: undefined,
|
||||
})
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
|
||||
nodeId: groupNode.id,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
|
||||
|
||||
// check if the current selection can be ungrouped (single selected Group node)
|
||||
const getCanUngroup = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length !== 1)
|
||||
return false
|
||||
|
||||
return selectedNodes[0].data.type === BlockEnum.Group
|
||||
}, [store])
|
||||
|
||||
// get the selected group node id for ungroup operation
|
||||
const getSelectedGroupId = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
|
||||
return selectedNodes[0].id
|
||||
|
||||
return undefined
|
||||
}, [store])
|
||||
|
||||
const handleUngroup = useCallback((groupId: string) => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const groupNode = nodes.find(n => n.id === groupId)
|
||||
|
||||
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
|
||||
return
|
||||
|
||||
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
|
||||
|
||||
// restore hidden member nodes
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (memberIds.has(node.id)) {
|
||||
node.hidden = false
|
||||
delete node.data._hiddenInGroupId
|
||||
}
|
||||
})
|
||||
// remove group node
|
||||
const groupIndex = draft.findIndex(n => n.id === groupId)
|
||||
if (groupIndex !== -1)
|
||||
draft.splice(groupIndex, 1)
|
||||
})
|
||||
|
||||
// restore hidden edges and remove temp edges in single pass O(E)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const indicesToRemove: number[] = []
|
||||
|
||||
for (let i = 0; i < draft.length; i++) {
|
||||
const edge = draft[i]
|
||||
// restore hidden edges that involve member nodes
|
||||
if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target)))
|
||||
edge.hidden = false
|
||||
// collect temp edges connected to group for removal
|
||||
if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId))
|
||||
indicesToRemove.push(i)
|
||||
}
|
||||
|
||||
// remove collected indices in reverse order to avoid index shift
|
||||
for (let i = indicesToRemove.length - 1; i >= 0; i--)
|
||||
draft.splice(indicesToRemove[i], 1)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
|
||||
nodeId: groupId,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, store])
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
handleNodeDrag,
|
||||
@@ -2859,17 +2098,11 @@ export const useNodesInteractions = () => {
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleMakeGroup,
|
||||
handleUngroup,
|
||||
handleNodeResize,
|
||||
handleNodeDisconnect,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
hasBundledNodes,
|
||||
getCanMakeGroup,
|
||||
getCanUngroup,
|
||||
getSelectedGroupId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import GroupDefault from '@/app/components/workflow/nodes/group/default'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@@ -27,7 +25,6 @@ export const useNodesMetaData = () => {
|
||||
}
|
||||
|
||||
export const useNodeMetaData = (node: Node) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
@@ -37,9 +34,6 @@ export const useNodeMetaData = (node: Node) => {
|
||||
const { data } = node
|
||||
const nodeMetaData = availableNodesMetaData.nodesMap?.[data.type]
|
||||
const author = useMemo(() => {
|
||||
if (data.type === BlockEnum.Group)
|
||||
return GroupDefault.metaData.author
|
||||
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author
|
||||
|
||||
@@ -54,9 +48,6 @@ export const useNodeMetaData = (node: Node) => {
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList])
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (data.type === BlockEnum.Group)
|
||||
return t('blocksAbout.group', { ns: 'workflow' })
|
||||
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language]
|
||||
if (data.type === BlockEnum.Tool) {
|
||||
@@ -67,7 +58,7 @@ export const useNodeMetaData = (node: Node) => {
|
||||
return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
}
|
||||
return nodeMetaData?.metaData.description
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language, t])
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language])
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -27,12 +27,6 @@ export const useShortcuts = (): void => {
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
hasBundledNodes,
|
||||
getCanMakeGroup,
|
||||
handleMakeGroup,
|
||||
getCanUngroup,
|
||||
getSelectedGroupId,
|
||||
handleUngroup,
|
||||
} = useNodesInteractions()
|
||||
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
@@ -84,8 +78,7 @@ export const useShortcuts = (): void => {
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
// Only intercept when nodes are selected via box selection
|
||||
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel && hasBundledNodes()) {
|
||||
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesCopy()
|
||||
}
|
||||
@@ -106,26 +99,6 @@ export const useShortcuts = (): void => {
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => {
|
||||
// Only intercept when the selection can be grouped
|
||||
if (shouldHandleShortcut(e) && getCanMakeGroup()) {
|
||||
e.preventDefault()
|
||||
// Close selection context menu if open
|
||||
workflowStore.setState({ selectionMenu: undefined })
|
||||
handleMakeGroup()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => {
|
||||
// Only intercept when the selection can be ungrouped
|
||||
if (shouldHandleShortcut(e) && getCanUngroup()) {
|
||||
e.preventDefault()
|
||||
const groupId = getSelectedGroupId()
|
||||
if (groupId)
|
||||
handleUngroup(groupId)
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
Connection,
|
||||
} from 'reactflow'
|
||||
import type { GroupNodeData } from '../nodes/group/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type {
|
||||
BlockEnum,
|
||||
Edge,
|
||||
Node,
|
||||
ValueSelector,
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
} from '../constants'
|
||||
import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_base/components/variable/utils'
|
||||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
|
||||
import { BlockEnum, WorkflowRunningStatus } from '../types'
|
||||
import {
|
||||
WorkflowRunningStatus,
|
||||
} from '../types'
|
||||
import {
|
||||
getWorkflowEntryNode,
|
||||
isWorkflowEntryNode,
|
||||
@@ -379,7 +381,7 @@ export const useWorkflow = () => {
|
||||
return startNodes
|
||||
}, [nodesMap, getRootNodesById])
|
||||
|
||||
const isValidConnection = useCallback(({ source, sourceHandle, target }: Connection) => {
|
||||
const isValidConnection = useCallback(({ source, sourceHandle: _sourceHandle, target }: Connection) => {
|
||||
const {
|
||||
edges,
|
||||
getNodes,
|
||||
@@ -394,42 +396,15 @@ export const useWorkflow = () => {
|
||||
if (sourceNode.parentId !== targetNode.parentId)
|
||||
return false
|
||||
|
||||
// For Group nodes, use the leaf node's type for validation
|
||||
// sourceHandle format: "${leafNodeId}-${originalSourceHandle}"
|
||||
let actualSourceType = sourceNode.data.type
|
||||
if (sourceNode.data.type === BlockEnum.Group && sourceHandle) {
|
||||
const lastDashIndex = sourceHandle.lastIndexOf('-')
|
||||
if (lastDashIndex > 0) {
|
||||
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
|
||||
const leafNode = nodes.find(node => node.id === leafNodeId)
|
||||
if (leafNode)
|
||||
actualSourceType = leafNode.data.type
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks
|
||||
const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNode.data.type, !!sourceNode.parentId).availableNextBlocks
|
||||
const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks
|
||||
|
||||
if (targetNode.data.type === BlockEnum.Group) {
|
||||
const groupData = targetNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
if (headNodeIds.length > 0) {
|
||||
const headNode = nodes.find(node => node.id === headNodeIds[0])
|
||||
if (headNode) {
|
||||
const headNodeAvailablePrevNodes = getAvailableBlocks(headNode.data.type, !!targetNode.parentId).availablePrevBlocks
|
||||
if (!headNodeAvailablePrevNodes.includes(actualSourceType))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
|
||||
return false
|
||||
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
|
||||
return false
|
||||
|
||||
if (!targetNodeAvailablePrevNodes.includes(actualSourceType))
|
||||
return false
|
||||
}
|
||||
if (!targetNodeAvailablePrevNodes.includes(sourceNode.data.type))
|
||||
return false
|
||||
}
|
||||
|
||||
const hasCycle = (node: Node, visited = new Set()) => {
|
||||
@@ -550,7 +525,6 @@ export const useIsNodeInLoop = (loopId: string) => {
|
||||
return false
|
||||
|
||||
if (node.parentId === loopId)
|
||||
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
@@ -54,14 +54,6 @@ import {
|
||||
} from './constants'
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
import CustomEdge from './custom-edge'
|
||||
import {
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_NODE,
|
||||
CustomGroupExitPortNode,
|
||||
CustomGroupInputNode,
|
||||
CustomGroupNode,
|
||||
} from './custom-group-node'
|
||||
import DatasetsDetailProvider from './datasets-detail-store/provider'
|
||||
import HelpLine from './help-line'
|
||||
import {
|
||||
@@ -120,9 +112,6 @@ const nodeTypes = {
|
||||
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
|
||||
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
|
||||
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
|
||||
[CUSTOM_GROUP_NODE]: CustomGroupNode,
|
||||
[CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode,
|
||||
[CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode,
|
||||
}
|
||||
const edgeTypes = {
|
||||
[CUSTOM_EDGE]: CustomEdge,
|
||||
|
||||
@@ -41,14 +41,13 @@ const PanelOperatorPopup = ({
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
handleUngroup,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const edge = edges.find(edge => edge.target === id)
|
||||
const nodeMetaData = useNodeMetaData({ id, data } as Node)
|
||||
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group
|
||||
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly
|
||||
const isChildNode = !!(data.isInIteration || data.isInLoop)
|
||||
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
@@ -62,25 +61,6 @@ const PanelOperatorPopup = ({
|
||||
|
||||
return (
|
||||
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{
|
||||
!nodesReadOnly && data.type === BlockEnum.Group && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleUngroup(id)
|
||||
}}
|
||||
>
|
||||
{t('panel.ungroup', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'shift', 'g']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
|
||||
<>
|
||||
|
||||
@@ -594,7 +594,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && data.type !== BlockEnum.Group && (
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
|
||||
<div className="flex items-center justify-between pl-4 pr-3">
|
||||
<Tab
|
||||
value={tabType}
|
||||
@@ -603,9 +603,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.type !== BlockEnum.Group && <Split />}
|
||||
<Split />
|
||||
</div>
|
||||
{(tabType === TabType.settings || data.type === BlockEnum.Group) && (
|
||||
{tabType === TabType.settings && (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
|
||||
@@ -56,7 +56,6 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
|
||||
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
|
||||
[BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams,
|
||||
[BlockEnum.Group]: undefined,
|
||||
[BlockEnum.VariableAssigner]: undefined,
|
||||
[BlockEnum.End]: undefined,
|
||||
[BlockEnum.Answer]: undefined,
|
||||
@@ -104,7 +103,6 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.DataSource]: undefined,
|
||||
[BlockEnum.DataSourceEmpty]: undefined,
|
||||
[BlockEnum.KnowledgeBase]: undefined,
|
||||
[BlockEnum.Group]: undefined,
|
||||
[BlockEnum.TriggerWebhook]: undefined,
|
||||
[BlockEnum.TriggerSchedule]: undefined,
|
||||
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,
|
||||
|
||||
@@ -221,7 +221,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && !data._isCandidate && (
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
|
||||
@@ -14,8 +14,6 @@ import DocExtractorNode from './document-extractor/node'
|
||||
import DocExtractorPanel from './document-extractor/panel'
|
||||
import EndNode from './end/node'
|
||||
import EndPanel from './end/panel'
|
||||
import GroupNode from './group/node'
|
||||
import GroupPanel from './group/panel'
|
||||
import HttpNode from './http/node'
|
||||
import HttpPanel from './http/panel'
|
||||
import IfElseNode from './if-else/node'
|
||||
@@ -77,7 +75,6 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
|
||||
[BlockEnum.Group]: GroupNode,
|
||||
}
|
||||
|
||||
export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
@@ -106,5 +103,4 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
|
||||
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
|
||||
[BlockEnum.Group]: GroupPanel,
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { GroupNodeData } from './types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 100,
|
||||
type: BlockEnum.Group,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<GroupNodeData> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
members: [],
|
||||
handlers: [],
|
||||
headNodeIds: [],
|
||||
leafNodeIds: [],
|
||||
},
|
||||
checkValid() {
|
||||
return {
|
||||
isValid: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
|
||||
import type { BlockEnum, NodeProps } from '@/app/components/workflow/types'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { NodeSourceHandle } from '../_base/components/node-handle'
|
||||
|
||||
const MAX_MEMBER_ICONS = 12
|
||||
|
||||
const GroupNode = (props: NodeProps<GroupNodeData>) => {
|
||||
const { data } = props
|
||||
|
||||
// show the explicitly passed members first; otherwise use the _children information to fill the type
|
||||
const members: GroupMember[] = useMemo(() => (
|
||||
data.members?.length
|
||||
? data.members
|
||||
: data._children?.length
|
||||
? data._children.map(child => ({
|
||||
id: child.nodeId,
|
||||
type: child.nodeType as BlockEnum,
|
||||
label: child.nodeType,
|
||||
}))
|
||||
: []
|
||||
), [data._children, data.members])
|
||||
|
||||
const handlers: GroupHandler[] = useMemo(() => (
|
||||
data.handlers?.length
|
||||
? data.handlers
|
||||
: members.length
|
||||
? members.map(member => ({
|
||||
id: `${member.id}-source`,
|
||||
label: member.label || member.id,
|
||||
nodeId: member.id,
|
||||
sourceHandle: 'source',
|
||||
}))
|
||||
: []
|
||||
), [data.handlers, members])
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3 pb-3">
|
||||
{members.length > 0 && (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
|
||||
{members.slice(0, MAX_MEMBER_ICONS).map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex h-7 items-center rounded-full bg-components-input-bg-normal px-1.5 shadow-xs"
|
||||
>
|
||||
<BlockIcon
|
||||
type={member.type}
|
||||
size="xs"
|
||||
className="!shadow-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{members.length > MAX_MEMBER_ICONS && (
|
||||
<div className="system-xs-medium rounded-full bg-components-input-bg-normal px-2 py-1 text-text-tertiary">
|
||||
+
|
||||
{members.length - MAX_MEMBER_ICONS}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RiArrowRightSLine className="ml-auto h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
{handlers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{handlers.map(handler => (
|
||||
<div
|
||||
key={handler.id}
|
||||
className={cn(
|
||||
'relative',
|
||||
'system-sm-semibold uppercase',
|
||||
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
|
||||
)}
|
||||
>
|
||||
{handler.label || handler.id}
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={handler.id}
|
||||
handleClassName="!top-1/2 !-translate-y-1/2 !-right-[21px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
GroupNode.displayName = 'GroupNode'
|
||||
|
||||
export default memo(GroupNode)
|
||||
@@ -1,9 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
const GroupPanel = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
GroupPanel.displayName = 'GroupPanel'
|
||||
|
||||
export default memo(GroupPanel)
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { BlockEnum, CommonNodeType } from '../../types'
|
||||
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
type: BlockEnum
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type GroupHandler = {
|
||||
id: string
|
||||
label?: string
|
||||
nodeId?: string // leaf node id for multi-branch nodes
|
||||
sourceHandle?: string // original sourceHandle (e.g., case_id for if-else)
|
||||
}
|
||||
|
||||
export type GroupNodeData = CommonNodeType<{
|
||||
members?: GroupMember[]
|
||||
handlers?: GroupHandler[]
|
||||
headNodeIds?: string[] // nodes that receive input from outside the group
|
||||
leafNodeIds?: string[] // nodes that send output to outside the group
|
||||
}>
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { FC, ReactElement } from 'react'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import {
|
||||
RiAlignBottom,
|
||||
RiAlignCenter,
|
||||
@@ -19,13 +17,9 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useMakeGroupAvailability } from './hooks/use-make-group'
|
||||
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useSelectionInteractions } from './hooks/use-selection-interactions'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { useStore, useWorkflowStore } from './store'
|
||||
|
||||
enum AlignType {
|
||||
@@ -39,67 +33,21 @@ enum AlignType {
|
||||
DistributeVertical = 'distributeVertical',
|
||||
}
|
||||
|
||||
type AlignButtonConfig = {
|
||||
type: AlignType
|
||||
icon: ReactElement
|
||||
labelKey: I18nKeysByPrefix<'workflow', 'operator.'>
|
||||
}
|
||||
|
||||
type AlignButtonProps = {
|
||||
config: AlignButtonConfig
|
||||
label: string
|
||||
onClick: (type: AlignType) => void
|
||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
const AlignButton: FC<AlignButtonProps> = ({ config, label, onClick, position = 'bottom' }) => {
|
||||
return (
|
||||
<Tooltip position={position} popupContent={label}>
|
||||
<div
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => onClick(config.type)}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const ALIGN_BUTTONS: AlignButtonConfig[] = [
|
||||
{ type: AlignType.Left, icon: <RiAlignLeft className="h-4 w-4" />, labelKey: 'alignLeft' },
|
||||
{ type: AlignType.Center, icon: <RiAlignCenter className="h-4 w-4" />, labelKey: 'alignCenter' },
|
||||
{ type: AlignType.Right, icon: <RiAlignRight className="h-4 w-4" />, labelKey: 'alignRight' },
|
||||
{ type: AlignType.DistributeHorizontal, icon: <RiAlignJustify className="h-4 w-4" />, labelKey: 'distributeHorizontal' },
|
||||
{ type: AlignType.Top, icon: <RiAlignTop className="h-4 w-4" />, labelKey: 'alignTop' },
|
||||
{ type: AlignType.Middle, icon: <RiAlignCenter className="h-4 w-4 rotate-90" />, labelKey: 'alignMiddle' },
|
||||
{ type: AlignType.Bottom, icon: <RiAlignBottom className="h-4 w-4" />, labelKey: 'alignBottom' },
|
||||
{ type: AlignType.DistributeVertical, icon: <RiAlignJustify className="h-4 w-4 rotate-90" />, labelKey: 'distributeVertical' },
|
||||
]
|
||||
|
||||
const SelectionContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const { getNodesReadOnly, nodesReadOnly } = useNodesReadOnly()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSelectionContextmenuCancel } = useSelectionInteractions()
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleMakeGroup,
|
||||
} = useNodesInteractions()
|
||||
const selectionMenu = useStore(s => s.selectionMenu)
|
||||
|
||||
// Access React Flow methods
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const selectedNodeIds = useReactFlowStore((state) => {
|
||||
const ids = state.getNodes().filter(node => node.selected).map(node => node.id)
|
||||
ids.sort()
|
||||
return ids
|
||||
}, shallow)
|
||||
|
||||
const { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds)
|
||||
// Get selected nodes for alignment logic
|
||||
const selectedNodes = useReactFlowStore(state =>
|
||||
state.getNodes().filter(node => node.selected),
|
||||
)
|
||||
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
@@ -117,9 +65,9 @@ const SelectionContextmenu = () => {
|
||||
if (container) {
|
||||
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect()
|
||||
|
||||
const menuWidth = 244
|
||||
const menuWidth = 240
|
||||
|
||||
const estimatedMenuHeight = 203
|
||||
const estimatedMenuHeight = 380
|
||||
|
||||
if (left + menuWidth > containerWidth)
|
||||
left = left - menuWidth
|
||||
@@ -139,9 +87,9 @@ const SelectionContextmenu = () => {
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionMenu && selectedNodeIds.length <= 1)
|
||||
if (selectionMenu && selectedNodes.length <= 1)
|
||||
handleSelectionContextmenuCancel()
|
||||
}, [selectionMenu, selectedNodeIds.length, handleSelectionContextmenuCancel])
|
||||
}, [selectionMenu, selectedNodes.length, handleSelectionContextmenuCancel])
|
||||
|
||||
// Handle align nodes logic
|
||||
const handleAlignNode = useCallback((currentNode: any, nodeToAlign: any, alignType: AlignType, minX: number, maxX: number, minY: number, maxY: number) => {
|
||||
@@ -300,7 +248,7 @@ const SelectionContextmenu = () => {
|
||||
}, [])
|
||||
|
||||
const handleAlignNodes = useCallback((alignType: AlignType) => {
|
||||
if (getNodesReadOnly() || selectedNodeIds.length <= 1) {
|
||||
if (getNodesReadOnly() || selectedNodes.length <= 1) {
|
||||
handleSelectionContextmenuCancel()
|
||||
return
|
||||
}
|
||||
@@ -311,6 +259,9 @@ const SelectionContextmenu = () => {
|
||||
// Get all current nodes
|
||||
const nodes = store.getState().getNodes()
|
||||
|
||||
// Get all selected nodes
|
||||
const selectedNodeIds = selectedNodes.map(node => node.id)
|
||||
|
||||
// Find container nodes and their children
|
||||
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
|
||||
// when the container is selected. This prevents child nodes from being moved outside their containers.
|
||||
@@ -416,7 +367,7 @@ const SelectionContextmenu = () => {
|
||||
catch (err) {
|
||||
console.error('Failed to update nodes:', err)
|
||||
}
|
||||
}, [getNodesReadOnly, handleAlignNode, handleDistributeNodes, handleSelectionContextmenuCancel, handleSyncWorkflowDraft, saveStateToHistory, selectedNodeIds, store, workflowStore])
|
||||
}, [store, workflowStore, selectedNodes, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory, handleSelectionContextmenuCancel, handleAlignNode, handleDistributeNodes])
|
||||
|
||||
if (!selectionMenu)
|
||||
return null
|
||||
@@ -430,75 +381,73 @@ const SelectionContextmenu = () => {
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div ref={menuRef} className="w-[244px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{!nodesReadOnly && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className={`flex h-8 items-center justify-between rounded-lg px-3 text-sm ${
|
||||
canMakeGroup
|
||||
? 'cursor-pointer text-text-secondary hover:bg-state-base-hover'
|
||||
: 'cursor-not-allowed text-text-disabled'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!canMakeGroup)
|
||||
return
|
||||
handleMakeGroup()
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('operator.makeGroup', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'g']} className={!canMakeGroup ? 'opacity-50' : ''} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
handleNodesCopy()
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('common.copy', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
handleNodesDuplicate()
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('common.duplicate', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
onClick={() => {
|
||||
handleNodesDelete()
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-1">
|
||||
{ALIGN_BUTTONS.map(config => (
|
||||
<AlignButton
|
||||
key={config.type}
|
||||
config={config}
|
||||
label={t(`operator.${config.labelKey}`, { ns: 'workflow' })}
|
||||
onClick={handleAlignNodes}
|
||||
/>
|
||||
))}
|
||||
<div ref={menuRef} className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.vertical', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Top)}
|
||||
>
|
||||
<RiAlignTop className="h-4 w-4" />
|
||||
{t('operator.alignTop', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Middle)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4 rotate-90" />
|
||||
{t('operator.alignMiddle', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Bottom)}
|
||||
>
|
||||
<RiAlignBottom className="h-4 w-4" />
|
||||
{t('operator.alignBottom', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeVertical)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4 rotate-90" />
|
||||
{t('operator.distributeVertical', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-medium px-2 py-2 text-text-tertiary">
|
||||
{t('operator.horizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Left)}
|
||||
>
|
||||
<RiAlignLeft className="h-4 w-4" />
|
||||
{t('operator.alignLeft', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Center)}
|
||||
>
|
||||
<RiAlignCenter className="h-4 w-4" />
|
||||
{t('operator.alignCenter', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.Right)}
|
||||
>
|
||||
<RiAlignRight className="h-4 w-4" />
|
||||
{t('operator.alignRight', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => handleAlignNodes(AlignType.DistributeHorizontal)}
|
||||
>
|
||||
<RiAlignJustify className="h-4 w-4" />
|
||||
{t('operator.distributeHorizontal', { ns: 'workflow' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,6 @@ export enum BlockEnum {
|
||||
Code = 'code',
|
||||
TemplateTransform = 'template-transform',
|
||||
HttpRequest = 'http-request',
|
||||
Group = 'group',
|
||||
VariableAssigner = 'variable-assigner',
|
||||
VariableAggregator = 'variable-aggregator',
|
||||
Tool = 'tool',
|
||||
@@ -80,7 +79,6 @@ export type CommonNodeType<T = {}> = {
|
||||
_isEntering?: boolean
|
||||
_showAddVariablePopup?: boolean
|
||||
_holdAddVariablePopup?: boolean
|
||||
_hiddenInGroupId?: string
|
||||
_iterationLength?: number
|
||||
_iterationIndex?: number
|
||||
_waitingRun?: boolean
|
||||
@@ -115,7 +113,6 @@ export type CommonEdgeType = {
|
||||
_connectedNodeIsHovering?: boolean
|
||||
_connectedNodeIsSelected?: boolean
|
||||
_isBundled?: boolean
|
||||
_hiddenInGroupId?: string
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { CustomGroupNodeData } from '../custom-group-node'
|
||||
import type { GroupNodeData } from '../nodes/group/types'
|
||||
import type { IfElseNodeType } from '../nodes/if-else/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type { QuestionClassifierNodeType } from '../nodes/question-classifier/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { Edge, Node } from '../types'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '../types'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { getConnectedEdges } from 'reactflow'
|
||||
import { getIterationStartNode, getLoopStartNode } from '@/app/components/workflow/utils/node'
|
||||
import {
|
||||
getConnectedEdges,
|
||||
} from 'reactflow'
|
||||
import { correctModelProvider } from '@/utils'
|
||||
import {
|
||||
getIterationStartNode,
|
||||
getLoopStartNode,
|
||||
} from '.'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
DEFAULT_RETRY_INTERVAL,
|
||||
@@ -19,22 +25,18 @@ import {
|
||||
NODE_WIDTH_X_OFFSET,
|
||||
START_INITIAL_POSITION,
|
||||
} from '../constants'
|
||||
import { CUSTOM_GROUP_NODE, GROUP_CHILDREN_Z_INDEX } from '../custom-group-node'
|
||||
import { branchNameCorrect } from '../nodes/if-else/utils'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
import { BlockEnum, ErrorHandleMode } from '../types'
|
||||
import {
|
||||
BlockEnum,
|
||||
ErrorHandleMode,
|
||||
} from '../types'
|
||||
|
||||
const WHITE = 'WHITE'
|
||||
const GRAY = 'GRAY'
|
||||
const BLACK = 'BLACK'
|
||||
|
||||
const isCyclicUtil = (
|
||||
nodeId: string,
|
||||
color: Record<string, string>,
|
||||
adjList: Record<string, string[]>,
|
||||
stack: string[],
|
||||
) => {
|
||||
const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Record<string, string[]>, stack: string[]) => {
|
||||
color[nodeId] = GRAY
|
||||
stack.push(nodeId)
|
||||
|
||||
@@ -45,12 +47,8 @@ const isCyclicUtil = (
|
||||
stack.push(childId)
|
||||
return true
|
||||
}
|
||||
if (
|
||||
color[childId] === WHITE
|
||||
&& isCyclicUtil(childId, color, adjList, stack)
|
||||
) {
|
||||
if (color[childId] === WHITE && isCyclicUtil(childId, color, adjList, stack))
|
||||
return true
|
||||
}
|
||||
}
|
||||
color[nodeId] = BLACK
|
||||
if (stack.length > 0 && stack[stack.length - 1] === nodeId)
|
||||
@@ -68,7 +66,8 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
adjList[node.id] = []
|
||||
}
|
||||
|
||||
for (const edge of edges) adjList[edge.source]?.push(edge.target)
|
||||
for (const edge of edges)
|
||||
adjList[edge.source]?.push(edge.target)
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (color[nodes[i].id] === WHITE)
|
||||
@@ -88,34 +87,20 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
|
||||
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
const hasIterationNode = nodes.some(
|
||||
node => node.data.type === BlockEnum.Iteration,
|
||||
)
|
||||
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
|
||||
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
|
||||
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
|
||||
const hasBusinessGroupNode = nodes.some(
|
||||
node => node.data.type === BlockEnum.Group,
|
||||
)
|
||||
|
||||
if (
|
||||
!hasIterationNode
|
||||
&& !hasLoopNode
|
||||
&& !hasGroupNode
|
||||
&& !hasBusinessGroupNode
|
||||
) {
|
||||
if (!hasIterationNode && !hasLoopNode) {
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
const nodesMap = nodes.reduce(
|
||||
(prev, next) => {
|
||||
prev[next.id] = next
|
||||
return prev
|
||||
},
|
||||
{} as Record<string, Node>,
|
||||
)
|
||||
const nodesMap = nodes.reduce((prev, next) => {
|
||||
prev[next.id] = next
|
||||
return prev
|
||||
}, {} as Record<string, Node>)
|
||||
|
||||
const iterationNodesWithStartNode = []
|
||||
const iterationNodesWithoutStartNode = []
|
||||
@@ -127,12 +112,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Iteration) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (
|
||||
nodesMap[currentNode.data.start_node_id]?.type
|
||||
!== CUSTOM_ITERATION_START_NODE
|
||||
) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
|
||||
iterationNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
}
|
||||
else {
|
||||
iterationNodesWithoutStartNode.push(currentNode)
|
||||
@@ -141,12 +122,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
|
||||
if (currentNode.data.type === BlockEnum.Loop) {
|
||||
if (currentNode.data.start_node_id) {
|
||||
if (
|
||||
nodesMap[currentNode.data.start_node_id]?.type
|
||||
!== CUSTOM_LOOP_START_NODE
|
||||
) {
|
||||
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
|
||||
loopNodesWithStartNode.push(currentNode)
|
||||
}
|
||||
}
|
||||
else {
|
||||
loopNodesWithoutStartNode.push(currentNode)
|
||||
@@ -155,10 +132,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
|
||||
const newIterationStartNodesMap = {} as Record<string, Node>
|
||||
const newIterationStartNodes = [
|
||||
...iterationNodesWithStartNode,
|
||||
...iterationNodesWithoutStartNode,
|
||||
].map((iterationNode, index) => {
|
||||
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
|
||||
const newNode = getIterationStartNode(iterationNode.id)
|
||||
newNode.id = newNode.id + index
|
||||
newIterationStartNodesMap[iterationNode.id] = newNode
|
||||
@@ -166,34 +140,24 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
})
|
||||
|
||||
const newLoopStartNodesMap = {} as Record<string, Node>
|
||||
const newLoopStartNodes = [
|
||||
...loopNodesWithStartNode,
|
||||
...loopNodesWithoutStartNode,
|
||||
].map((loopNode, index) => {
|
||||
const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => {
|
||||
const newNode = getLoopStartNode(loopNode.id)
|
||||
newNode.id = newNode.id + index
|
||||
newLoopStartNodesMap[loopNode.id] = newNode
|
||||
return newNode
|
||||
})
|
||||
|
||||
const newEdges = [
|
||||
...iterationNodesWithStartNode,
|
||||
...loopNodesWithStartNode,
|
||||
].map((nodeItem) => {
|
||||
const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => {
|
||||
const isIteration = nodeItem.data.type === BlockEnum.Iteration
|
||||
const newNode = (
|
||||
isIteration ? newIterationStartNodesMap : newLoopStartNodesMap
|
||||
)[nodeItem.id]
|
||||
const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id]
|
||||
const startNode = nodesMap[nodeItem.data.start_node_id]
|
||||
const source = newNode.id
|
||||
const sourceHandle = 'source'
|
||||
const target = startNode.id
|
||||
const targetHandle = 'target'
|
||||
|
||||
const parentNode
|
||||
= nodes.find(node => node.id === startNode.parentId) || null
|
||||
const isInIteration
|
||||
= !!parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const parentNode = nodes.find(node => node.id === startNode.parentId) || null
|
||||
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
return {
|
||||
@@ -216,159 +180,21 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
if (
|
||||
node.data.type === BlockEnum.Iteration
|
||||
&& newIterationStartNodesMap[node.id]
|
||||
) {
|
||||
(node.data as IterationNodeType).start_node_id
|
||||
= newIterationStartNodesMap[node.id].id
|
||||
}
|
||||
if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id])
|
||||
(node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id
|
||||
|
||||
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id]) {
|
||||
(node.data as LoopNodeType).start_node_id
|
||||
= newLoopStartNodesMap[node.id].id
|
||||
}
|
||||
})
|
||||
|
||||
// Derive Group internal edges (input → entries, leaves → exits)
|
||||
const groupInternalEdges: Edge[] = []
|
||||
const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE)
|
||||
|
||||
for (const groupNode of groupNodes) {
|
||||
const groupData = groupNode.data as unknown as CustomGroupNodeData
|
||||
const { group } = groupData
|
||||
|
||||
if (!group)
|
||||
continue
|
||||
|
||||
const { inputNodeId, entryNodeIds, exitPorts } = group
|
||||
|
||||
// Derive edges: input → each entry node
|
||||
for (const entryId of entryNodeIds) {
|
||||
const entryNode = nodesMap[entryId]
|
||||
if (entryNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${inputNodeId}-source-${entryId}-target`,
|
||||
type: 'custom',
|
||||
source: inputNodeId,
|
||||
sourceHandle: 'source',
|
||||
target: entryId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: '' as any, // Group input has empty type
|
||||
targetType: entryNode.data.type,
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive edges: each leaf node → exit port
|
||||
for (const exitPort of exitPorts) {
|
||||
const leafNode = nodesMap[exitPort.leafNodeId]
|
||||
if (leafNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`,
|
||||
type: 'custom',
|
||||
source: exitPort.leafNodeId,
|
||||
sourceHandle: exitPort.sourceHandle,
|
||||
target: exitPort.portNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: leafNode.data.type,
|
||||
targetType: '' as string, // Exit port has empty type
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild isTemp edges for business Group nodes (BlockEnum.Group)
|
||||
// These edges connect the group node to external nodes for visual display
|
||||
const groupTempEdges: Edge[] = []
|
||||
const inboundEdgeIds = new Set<string>()
|
||||
|
||||
nodes.forEach((groupNode) => {
|
||||
if (groupNode.data.type !== BlockEnum.Group)
|
||||
return
|
||||
|
||||
const groupData = groupNode.data as GroupNodeData
|
||||
const {
|
||||
members = [],
|
||||
headNodeIds = [],
|
||||
leafNodeIds = [],
|
||||
handlers = [],
|
||||
} = groupData
|
||||
const memberSet = new Set(members.map(m => m.id))
|
||||
const headSet = new Set(headNodeIds)
|
||||
const leafSet = new Set(leafNodeIds)
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// Inbound edge: source outside group, target is a head node
|
||||
// Use Set to dedupe since multiple head nodes may share same external source
|
||||
if (!memberSet.has(edge.source) && headSet.has(edge.target)) {
|
||||
const sourceHandle = edge.sourceHandle || 'source'
|
||||
const edgeId = `${edge.source}-${sourceHandle}-${groupNode.id}-target`
|
||||
if (!inboundEdgeIds.has(edgeId)) {
|
||||
inboundEdgeIds.add(edgeId)
|
||||
groupTempEdges.push({
|
||||
id: edgeId,
|
||||
type: 'custom',
|
||||
source: edge.source,
|
||||
sourceHandle,
|
||||
target: groupNode.id,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: edge.data?.sourceType,
|
||||
targetType: BlockEnum.Group,
|
||||
_isTemp: true,
|
||||
},
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound edge: source is a leaf node, target outside group
|
||||
if (leafSet.has(edge.source) && !memberSet.has(edge.target)) {
|
||||
const edgeSourceHandle = edge.sourceHandle || 'source'
|
||||
const handler = handlers.find(
|
||||
h =>
|
||||
h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
|
||||
)
|
||||
if (handler) {
|
||||
groupTempEdges.push({
|
||||
id: `${groupNode.id}-${handler.id}-${edge.target}-${edge.targetHandle}`,
|
||||
type: 'custom',
|
||||
source: groupNode.id,
|
||||
sourceHandle: handler.id,
|
||||
target: edge.target!,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: {
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: edge.data?.targetType,
|
||||
_isTemp: true,
|
||||
},
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id])
|
||||
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
|
||||
edges: [...edges, ...newEdges, ...groupInternalEdges, ...groupTempEdges],
|
||||
edges: [...edges, ...newEdges],
|
||||
}
|
||||
}
|
||||
|
||||
export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
const { nodes, edges } = preprocessNodesAndEdges(
|
||||
cloneDeep(originNodes),
|
||||
cloneDeep(originEdges),
|
||||
)
|
||||
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
|
||||
const firstNode = nodes[0]
|
||||
|
||||
if (!firstNode?.position) {
|
||||
@@ -380,35 +206,23 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
const iterationOrLoopNodeMap = nodes.reduce(
|
||||
(acc, node) => {
|
||||
if (node.parentId) {
|
||||
if (acc[node.parentId]) {
|
||||
acc[node.parentId].push({
|
||||
nodeId: node.id,
|
||||
nodeType: node.data.type,
|
||||
})
|
||||
}
|
||||
else {
|
||||
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>,
|
||||
)
|
||||
const iterationOrLoopNodeMap = nodes.reduce((acc, node) => {
|
||||
if (node.parentId) {
|
||||
if (acc[node.parentId])
|
||||
acc[node.parentId].push({ nodeId: node.id, nodeType: node.data.type })
|
||||
else
|
||||
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>)
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (!node.type)
|
||||
node.type = CUSTOM_NODE
|
||||
|
||||
const connectedEdges = getConnectedEdges([node], edges)
|
||||
node.data._connectedSourceHandleIds = connectedEdges
|
||||
.filter(edge => edge.source === node.id)
|
||||
.map(edge => edge.sourceHandle || 'source')
|
||||
node.data._connectedTargetHandleIds = connectedEdges
|
||||
.filter(edge => edge.target === node.id)
|
||||
.map(edge => edge.targetHandle || 'target')
|
||||
node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source')
|
||||
node.data._connectedTargetHandleIds = connectedEdges.filter(edge => edge.target === node.id).map(edge => edge.targetHandle || 'target')
|
||||
|
||||
if (node.data.type === BlockEnum.IfElse) {
|
||||
const nodeData = node.data as IfElseNodeType
|
||||
@@ -423,86 +237,49 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
]
|
||||
}
|
||||
node.data._targetBranches = branchNameCorrect([
|
||||
...(node.data as IfElseNodeType).cases.map(item => ({
|
||||
id: item.case_id,
|
||||
name: '',
|
||||
})),
|
||||
...(node.data as IfElseNodeType).cases.map(item => ({ id: item.case_id, name: '' })),
|
||||
{ id: 'false', name: '' },
|
||||
])
|
||||
// delete conditions and logical_operator if cases is not empty
|
||||
if (
|
||||
nodeData.cases.length > 0
|
||||
&& nodeData.conditions
|
||||
&& nodeData.logical_operator
|
||||
) {
|
||||
if (nodeData.cases.length > 0 && nodeData.conditions && nodeData.logical_operator) {
|
||||
delete nodeData.conditions
|
||||
delete nodeData.logical_operator
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.QuestionClassifier) {
|
||||
node.data._targetBranches = (
|
||||
node.data as QuestionClassifierNodeType
|
||||
).classes.map((topic) => {
|
||||
node.data._targetBranches = (node.data as QuestionClassifierNodeType).classes.map((topic) => {
|
||||
return topic
|
||||
})
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Group) {
|
||||
const groupData = node.data as GroupNodeData
|
||||
if (groupData.handlers?.length) {
|
||||
node.data._targetBranches = groupData.handlers.map(handler => ({
|
||||
id: handler.id,
|
||||
name: handler.label || handler.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Iteration) {
|
||||
const iterationNodeData = node.data as IterationNodeType
|
||||
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
iterationNodeData.is_parallel = iterationNodeData.is_parallel || false
|
||||
iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10
|
||||
iterationNodeData.error_handle_mode
|
||||
= iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
}
|
||||
|
||||
// TODO: loop error handle mode
|
||||
if (node.data.type === BlockEnum.Loop) {
|
||||
const loopNodeData = node.data as LoopNodeType
|
||||
loopNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
loopNodeData.error_handle_mode
|
||||
= loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
|
||||
}
|
||||
|
||||
// legacy provider handle
|
||||
if (node.data.type === BlockEnum.LLM) {
|
||||
(node as any).data.model.provider = correctModelProvider(
|
||||
(node as any).data.model.provider,
|
||||
)
|
||||
}
|
||||
if (node.data.type === BlockEnum.LLM)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (
|
||||
node.data.type === BlockEnum.KnowledgeRetrieval
|
||||
&& (node as any).data.multiple_retrieval_config?.reranking_model
|
||||
) {
|
||||
(node as any).data.multiple_retrieval_config.reranking_model.provider
|
||||
= correctModelProvider(
|
||||
(node as any).data.multiple_retrieval_config?.reranking_model.provider,
|
||||
)
|
||||
}
|
||||
if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config?.reranking_model)
|
||||
(node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider((node as any).data.multiple_retrieval_config?.reranking_model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.QuestionClassifier) {
|
||||
(node as any).data.model.provider = correctModelProvider(
|
||||
(node as any).data.model.provider,
|
||||
)
|
||||
}
|
||||
if (node.data.type === BlockEnum.QuestionClassifier)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.ParameterExtractor) {
|
||||
(node as any).data.model.provider = correctModelProvider(
|
||||
(node as any).data.model.provider,
|
||||
)
|
||||
}
|
||||
if (node.data.type === BlockEnum.ParameterExtractor)
|
||||
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
|
||||
|
||||
if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
|
||||
node.data.retry_config = {
|
||||
@@ -512,21 +289,14 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
node.data.type === BlockEnum.Tool
|
||||
&& !(node as Node<ToolNodeType>).data.version
|
||||
&& !(node as Node<ToolNodeType>).data.tool_node_version
|
||||
) {
|
||||
if (node.data.type === BlockEnum.Tool && !(node as Node<ToolNodeType>).data.version && !(node as Node<ToolNodeType>).data.tool_node_version) {
|
||||
(node as Node<ToolNodeType>).data.tool_node_version = '2'
|
||||
|
||||
const toolConfigurations = (node as Node<ToolNodeType>).data.tool_configurations
|
||||
if (toolConfigurations && Object.keys(toolConfigurations).length > 0) {
|
||||
const newValues = { ...toolConfigurations }
|
||||
Object.keys(toolConfigurations).forEach((key) => {
|
||||
if (
|
||||
typeof toolConfigurations[key] !== 'object'
|
||||
|| toolConfigurations[key] === null
|
||||
) {
|
||||
if (typeof toolConfigurations[key] !== 'object' || toolConfigurations[key] === null) {
|
||||
newValues[key] = {
|
||||
type: 'constant',
|
||||
value: toolConfigurations[key],
|
||||
@@ -542,62 +312,50 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
}
|
||||
|
||||
export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
|
||||
const { nodes, edges } = preprocessNodesAndEdges(
|
||||
cloneDeep(originNodes),
|
||||
cloneDeep(originEdges),
|
||||
)
|
||||
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
|
||||
let selectedNode: Node | null = null
|
||||
const nodesMap = nodes.reduce(
|
||||
(acc, node) => {
|
||||
acc[node.id] = node
|
||||
const nodesMap = nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node
|
||||
|
||||
if (node.data?.selected)
|
||||
selectedNode = node
|
||||
if (node.data?.selected)
|
||||
selectedNode = node
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Node>,
|
||||
)
|
||||
return acc
|
||||
}, {} as Record<string, Node>)
|
||||
|
||||
const cycleEdges = getCycleEdges(nodes, edges)
|
||||
return edges
|
||||
.filter((edge) => {
|
||||
return !cycleEdges.find(
|
||||
cycEdge =>
|
||||
cycEdge.source === edge.source && cycEdge.target === edge.target,
|
||||
)
|
||||
})
|
||||
.map((edge) => {
|
||||
edge.type = 'custom'
|
||||
return edges.filter((edge) => {
|
||||
return !cycleEdges.find(cycEdge => cycEdge.source === edge.source && cycEdge.target === edge.target)
|
||||
}).map((edge) => {
|
||||
edge.type = 'custom'
|
||||
|
||||
if (!edge.sourceHandle)
|
||||
edge.sourceHandle = 'source'
|
||||
if (!edge.sourceHandle)
|
||||
edge.sourceHandle = 'source'
|
||||
|
||||
if (!edge.targetHandle)
|
||||
edge.targetHandle = 'target'
|
||||
if (!edge.targetHandle)
|
||||
edge.targetHandle = 'target'
|
||||
|
||||
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
sourceType: nodesMap[edge.source].data.type!,
|
||||
} as any
|
||||
}
|
||||
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
sourceType: nodesMap[edge.source].data.type!,
|
||||
} as any
|
||||
}
|
||||
|
||||
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
targetType: nodesMap[edge.target].data.type!,
|
||||
} as any
|
||||
}
|
||||
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
targetType: nodesMap[edge.target].data.type!,
|
||||
} as any
|
||||
}
|
||||
|
||||
if (selectedNode) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
_connectedNodeIsSelected:
|
||||
edge.source === selectedNode.id || edge.target === selectedNode.id,
|
||||
} as any
|
||||
}
|
||||
if (selectedNode) {
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
_connectedNodeIsSelected: edge.source === selectedNode.id || edge.target === selectedNode.id,
|
||||
} as any
|
||||
}
|
||||
|
||||
return edge
|
||||
})
|
||||
return edge
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,95 +157,6 @@ export const getValidTreeNodes = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getCommonPredecessorNodeIds = (selectedNodeIds: string[], edges: Edge[]) => {
|
||||
const uniqSelectedNodeIds = Array.from(new Set(selectedNodeIds))
|
||||
if (uniqSelectedNodeIds.length <= 1)
|
||||
return []
|
||||
|
||||
const selectedNodeIdSet = new Set(uniqSelectedNodeIds)
|
||||
const predecessorNodeIdsMap = new Map<string, Set<string>>()
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (!selectedNodeIdSet.has(edge.target))
|
||||
return
|
||||
|
||||
const predecessors = predecessorNodeIdsMap.get(edge.target) ?? new Set<string>()
|
||||
predecessors.add(edge.source)
|
||||
predecessorNodeIdsMap.set(edge.target, predecessors)
|
||||
})
|
||||
|
||||
let commonPredecessorNodeIds: Set<string> | null = null
|
||||
|
||||
uniqSelectedNodeIds.forEach((nodeId) => {
|
||||
const predecessors = predecessorNodeIdsMap.get(nodeId) ?? new Set<string>()
|
||||
|
||||
if (!commonPredecessorNodeIds) {
|
||||
commonPredecessorNodeIds = new Set(predecessors)
|
||||
return
|
||||
}
|
||||
|
||||
Array.from(commonPredecessorNodeIds).forEach((predecessorNodeId) => {
|
||||
if (!predecessors.has(predecessorNodeId))
|
||||
commonPredecessorNodeIds!.delete(predecessorNodeId)
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(commonPredecessorNodeIds ?? []).sort()
|
||||
}
|
||||
|
||||
export type PredecessorHandle = {
|
||||
nodeId: string
|
||||
handleId: string
|
||||
}
|
||||
|
||||
export const getCommonPredecessorHandles = (targetNodeIds: string[], edges: Edge[]): PredecessorHandle[] => {
|
||||
const uniqTargetNodeIds = Array.from(new Set(targetNodeIds))
|
||||
if (uniqTargetNodeIds.length === 0)
|
||||
return []
|
||||
|
||||
// Get the "direct predecessor handler", which is:
|
||||
// - edge.source (predecessor node)
|
||||
// - edge.sourceHandle (the specific output handle of the predecessor; defaults to 'source' if not set)
|
||||
// Used to handle multi-handle branch scenarios like If-Else / Classifier.
|
||||
const targetNodeIdSet = new Set(uniqTargetNodeIds)
|
||||
const predecessorHandleMap = new Map<string, Set<string>>() // targetNodeId -> Set<`${source}\0${handleId}`>
|
||||
const delimiter = '\u0000'
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (!targetNodeIdSet.has(edge.target))
|
||||
return
|
||||
|
||||
const predecessors = predecessorHandleMap.get(edge.target) ?? new Set<string>()
|
||||
const handleId = edge.sourceHandle || 'source'
|
||||
predecessors.add(`${edge.source}${delimiter}${handleId}`)
|
||||
predecessorHandleMap.set(edge.target, predecessors)
|
||||
})
|
||||
|
||||
// Intersect predecessor handlers of all targets, keeping only handlers common to all targets.
|
||||
let commonKeys: Set<string> | null = null
|
||||
|
||||
uniqTargetNodeIds.forEach((nodeId) => {
|
||||
const keys = predecessorHandleMap.get(nodeId) ?? new Set<string>()
|
||||
|
||||
if (!commonKeys) {
|
||||
commonKeys = new Set(keys)
|
||||
return
|
||||
}
|
||||
|
||||
Array.from(commonKeys).forEach((key) => {
|
||||
if (!keys.has(key))
|
||||
commonKeys!.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from<string>(commonKeys ?? [])
|
||||
.map((key) => {
|
||||
const [nodeId, handleId] = key.split(delimiter)
|
||||
return { nodeId, handleId }
|
||||
})
|
||||
.sort((a, b) => a.nodeId.localeCompare(b.nodeId) || a.handleId.localeCompare(b.handleId))
|
||||
}
|
||||
|
||||
export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => {
|
||||
const idMap = nodes.reduce((acc, node) => {
|
||||
acc[node.id] = uuid4()
|
||||
|
||||
163
web/app/forgot-password/ForgotPasswordForm.spec.tsx
Normal file
163
web/app/forgot-password/ForgotPasswordForm.spec.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fetchInitValidateStatus, fetchSetupStatus, sendForgotPasswordEmail } from '@/service/common'
|
||||
import ForgotPasswordForm from './ForgotPasswordForm'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
fetchInitValidateStatus: vi.fn(),
|
||||
sendForgotPasswordEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
|
||||
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
|
||||
const mockSendForgotPasswordEmail = vi.mocked(sendForgotPasswordEmail)
|
||||
|
||||
const prepareLoadedState = () => {
|
||||
mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse)
|
||||
mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse)
|
||||
}
|
||||
|
||||
describe('ForgotPasswordForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prepareLoadedState()
|
||||
})
|
||||
|
||||
it('should render form after loading', async () => {
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
expect(await screen.findByLabelText('login.email')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show validation error when email is empty', async () => {
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
await screen.findByLabelText('login.email')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('login.error.emailInValid')).toBeInTheDocument()
|
||||
})
|
||||
expect(mockSendForgotPasswordEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should send reset email and navigate after confirmation', async () => {
|
||||
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'success', data: 'ok' } as any)
|
||||
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
const emailInput = await screen.findByLabelText('login.email')
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({
|
||||
url: '/forgot-password',
|
||||
body: { email: 'test@example.com' },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.backToSignIn/ }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
it('should submit when form is submitted', async () => {
|
||||
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'success', data: 'ok' } as any)
|
||||
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
|
||||
|
||||
const form = screen.getByRole('button', { name: /login\.sendResetLink/ }).closest('form')
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendForgotPasswordEmail).toHaveBeenCalledWith({
|
||||
url: '/forgot-password',
|
||||
body: { email: 'test@example.com' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable submit while request is in flight', async () => {
|
||||
let resolveRequest: ((value: any) => void) | undefined
|
||||
const requestPromise = new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
mockSendForgotPasswordEmail.mockReturnValue(requestPromise as any)
|
||||
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
|
||||
|
||||
const button = screen.getByRole('button', { name: /login\.sendResetLink/ })
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(mockSendForgotPasswordEmail).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRequest?.({ result: 'success', data: 'ok' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /login\.backToSignIn/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep form state when request fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockSendForgotPasswordEmail.mockResolvedValue({ result: 'fail', data: 'error' } as any)
|
||||
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'test@example.com' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.sendResetLink/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendForgotPasswordEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /login\.sendResetLink/ })).toBeInTheDocument()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should redirect to init when status is not started', async () => {
|
||||
const originalLocation = window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
})
|
||||
mockFetchInitValidateStatus.mockResolvedValue({ status: 'not_started' } as InitValidateStatusResponse)
|
||||
|
||||
render(<ForgotPasswordForm />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe('/init')
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,16 @@
|
||||
'use client'
|
||||
import type { InitValidateStatusResponse } from '@/models/common'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { formContext, useAppForm } from '@/app/components/base/form'
|
||||
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
|
||||
import {
|
||||
fetchInitValidateStatus,
|
||||
fetchSetupStatus,
|
||||
@@ -27,44 +28,45 @@ const accountFormSchema = z.object({
|
||||
.email('error.emailInValid'),
|
||||
})
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||
|
||||
const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isEmailSent, setIsEmailSent] = useState(false)
|
||||
const { register, trigger, getValues, formState: { errors } } = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { email: '' },
|
||||
validators: {
|
||||
onSubmit: zodSubmitValidator(accountFormSchema),
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
const res = await sendForgotPasswordEmail({
|
||||
url: '/forgot-password',
|
||||
body: { email: value.email },
|
||||
})
|
||||
if (res.result === 'success')
|
||||
setIsEmailSent(true)
|
||||
else console.error('Email verification failed')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Request failed:', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSendResetPasswordEmail = async (email: string) => {
|
||||
try {
|
||||
const res = await sendForgotPasswordEmail({
|
||||
url: '/forgot-password',
|
||||
body: { email },
|
||||
})
|
||||
if (res.result === 'success')
|
||||
setIsEmailSent(true)
|
||||
|
||||
else console.error('Email verification failed')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Request failed:', error)
|
||||
}
|
||||
}
|
||||
const isSubmitting = useStore(form.store, state => state.isSubmitting)
|
||||
const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors)
|
||||
|
||||
const handleSendResetPasswordClick = async () => {
|
||||
if (isSubmitting)
|
||||
return
|
||||
|
||||
if (isEmailSent) {
|
||||
router.push('/signin')
|
||||
}
|
||||
else {
|
||||
const isValid = await trigger('email')
|
||||
if (isValid) {
|
||||
const email = getValues('email')
|
||||
await handleSendResetPasswordEmail(email)
|
||||
}
|
||||
form.handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,30 +96,51 @@ const ForgotPasswordForm = () => {
|
||||
</div>
|
||||
<div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="relative">
|
||||
<form>
|
||||
{!isEmailSent && (
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"
|
||||
>
|
||||
{t('email', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Input
|
||||
{...register('email')}
|
||||
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
|
||||
/>
|
||||
{errors.email && <span className="text-sm text-red-400">{t(`${errors.email?.message}` as 'error.emailInValid', { ns: 'login' })}</span>}
|
||||
<formContext.Provider value={form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
{!isEmailSent && (
|
||||
<div className="mb-5">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="my-2 flex items-center justify-between text-sm font-medium text-text-primary"
|
||||
>
|
||||
{t('email', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<form.AppField
|
||||
name="email"
|
||||
>
|
||||
{field => (
|
||||
<Input
|
||||
id="email"
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
{emailErrors && emailErrors.length > 0 && (
|
||||
<span className="text-sm text-red-400">
|
||||
{t(`${emailErrors[0]}` as 'error.emailInValid', { ns: 'login' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button variant="primary" className="w-full" disabled={isSubmitting} onClick={handleSendResetPasswordClick}>
|
||||
{isEmailSent ? t('backToSignIn', { ns: 'login' }) : t('sendResetLink', { ns: 'login' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button variant="primary" className="w-full" onClick={handleSendResetPasswordClick}>
|
||||
{isEmailSent ? t('backToSignIn', { ns: 'login' }) : t('sendResetLink', { ns: 'login' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</formContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
158
web/app/install/installForm.spec.tsx
Normal file
158
web/app/install/installForm.spec.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
|
||||
import { encryptPassword } from '@/utils/encryption'
|
||||
import InstallForm from './installForm'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
fetchInitValidateStatus: vi.fn(),
|
||||
setup: vi.fn(),
|
||||
login: vi.fn(),
|
||||
getSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
|
||||
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
|
||||
const mockSetup = vi.mocked(setup)
|
||||
const mockLogin = vi.mocked(login)
|
||||
|
||||
const prepareLoadedState = () => {
|
||||
mockFetchSetupStatus.mockResolvedValue({ step: 'not_started' } as SetupStatusResponse)
|
||||
mockFetchInitValidateStatus.mockResolvedValue({ status: 'finished' } as InitValidateStatusResponse)
|
||||
}
|
||||
|
||||
describe('InstallForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prepareLoadedState()
|
||||
})
|
||||
|
||||
it('should render form after loading', async () => {
|
||||
render(<InstallForm />)
|
||||
|
||||
expect(await screen.findByLabelText('login.email')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /login\.installBtn/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show validation error when required fields are empty', async () => {
|
||||
render(<InstallForm />)
|
||||
|
||||
await screen.findByLabelText('login.email')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.installBtn/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('login.error.emailInValid')).toBeInTheDocument()
|
||||
expect(screen.getByText('login.error.nameEmpty')).toBeInTheDocument()
|
||||
})
|
||||
expect(mockSetup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit and redirect to apps on successful login', async () => {
|
||||
mockSetup.mockResolvedValue({ result: 'success' } as any)
|
||||
mockLogin.mockResolvedValue({ result: 'success', data: { access_token: 'token' } } as any)
|
||||
|
||||
render(<InstallForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
|
||||
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
|
||||
|
||||
const form = screen.getByRole('button', { name: /login\.installBtn/ }).closest('form')
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.submit(form as HTMLFormElement)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetup).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin',
|
||||
password: 'Password123',
|
||||
language: 'en',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: 'admin@example.com',
|
||||
password: encryptPassword('Password123'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to sign in when login fails', async () => {
|
||||
mockSetup.mockResolvedValue({ result: 'success' } as any)
|
||||
mockLogin.mockResolvedValue({ result: 'fail', data: 'error', code: 'login_failed', message: 'login failed' } as any)
|
||||
|
||||
render(<InstallForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
|
||||
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /login\.installBtn/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable submit while request is in flight', async () => {
|
||||
let resolveSetup: ((value: any) => void) | undefined
|
||||
const setupPromise = new Promise((resolve) => {
|
||||
resolveSetup = resolve
|
||||
})
|
||||
mockSetup.mockReturnValue(setupPromise as any)
|
||||
mockLogin.mockResolvedValue({ result: 'success', data: { access_token: 'token' } } as any)
|
||||
|
||||
render(<InstallForm />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('login.email'), { target: { value: 'admin@example.com' } })
|
||||
fireEvent.change(screen.getByLabelText('login.name'), { target: { value: 'Admin' } })
|
||||
fireEvent.change(screen.getByLabelText('login.password'), { target: { value: 'Password123' } })
|
||||
|
||||
const button = screen.getByRole('button', { name: /login\.installBtn/ })
|
||||
fireEvent.click(button)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(mockSetup).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSetup?.({ result: 'success' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to sign in when setup is finished', async () => {
|
||||
mockFetchSetupStatus.mockResolvedValue({ step: 'finished' } as SetupStatusResponse)
|
||||
|
||||
render(<InstallForm />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('setup_status', 'finished')
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,17 @@
|
||||
'use client'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { formContext, useAppForm } from '@/app/components/base/form'
|
||||
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { validPassword } from '@/config'
|
||||
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
@@ -33,8 +32,6 @@ const accountFormSchema = z.object({
|
||||
}).regex(validPassword, 'error.passwordInvalid'),
|
||||
})
|
||||
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>
|
||||
|
||||
const InstallForm = () => {
|
||||
useDocumentTitle('')
|
||||
const { t, i18n } = useTranslation()
|
||||
@@ -42,64 +39,49 @@ const InstallForm = () => {
|
||||
const router = useRouter()
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
password: '',
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
validators: {
|
||||
onSubmit: zodSubmitValidator(accountFormSchema),
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
// First, setup the admin account
|
||||
await setup({
|
||||
body: {
|
||||
...value,
|
||||
language: i18n.language,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<AccountFormValues> = async (data) => {
|
||||
// First, setup the admin account
|
||||
await setup({
|
||||
body: {
|
||||
...data,
|
||||
language: i18n.language,
|
||||
},
|
||||
})
|
||||
// Then, automatically login with the same credentials
|
||||
const loginRes = await login({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: value.email,
|
||||
password: encodePassword(value.password),
|
||||
},
|
||||
})
|
||||
|
||||
// Then, automatically login with the same credentials
|
||||
const loginRes = await login({
|
||||
url: '/login',
|
||||
body: {
|
||||
email: data.email,
|
||||
password: encodePassword(data.password),
|
||||
},
|
||||
})
|
||||
|
||||
// Store tokens and redirect to apps if login successful
|
||||
if (loginRes.result === 'success') {
|
||||
router.replace('/apps')
|
||||
}
|
||||
else {
|
||||
// Fallback to signin page if auto-login fails
|
||||
router.replace('/signin')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetting = async () => {
|
||||
if (isSubmitting)
|
||||
return
|
||||
handleSubmit(onSubmit)()
|
||||
}
|
||||
|
||||
const { run: debouncedHandleKeyDown } = useDebounceFn(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSetting()
|
||||
// Store tokens and redirect to apps if login successful
|
||||
if (loginRes.result === 'success') {
|
||||
router.replace('/apps')
|
||||
}
|
||||
else {
|
||||
// Fallback to signin page if auto-login fails
|
||||
router.replace('/signin')
|
||||
}
|
||||
},
|
||||
{ wait: 200 },
|
||||
)
|
||||
})
|
||||
|
||||
const handleKeyDown = useCallback(debouncedHandleKeyDown, [debouncedHandleKeyDown])
|
||||
const isSubmitting = useStore(form.store, state => state.isSubmitting)
|
||||
const emailErrors = useStore(form.store, state => state.fieldMeta.email?.errors)
|
||||
const nameErrors = useStore(form.store, state => state.fieldMeta.name?.errors)
|
||||
const passwordErrors = useStore(form.store, state => state.fieldMeta.password?.errors)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSetupStatus().then((res: SetupStatusResponse) => {
|
||||
@@ -128,76 +110,111 @@ const InstallForm = () => {
|
||||
</div>
|
||||
<div className="mt-8 grow sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSubmit(onSubmit)} onKeyDown={handleKeyDown}>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('email', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
{...register('email')}
|
||||
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
|
||||
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
/>
|
||||
{errors.email && <span className="text-sm text-red-400">{t(`${errors.email?.message}` as 'error.emailInValid', { ns: 'login' })}</span>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('name', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
{...register('name')}
|
||||
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
|
||||
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && <span className="text-sm text-red-400">{t(`${errors.name.message}` as 'error.nameEmpty', { ns: 'login' })}</span>}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('password', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="relative mt-1 rounded-md shadow-sm">
|
||||
<input
|
||||
{...register('password')}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
|
||||
className="system-sm-regular w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal px-3 py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-none"
|
||||
>
|
||||
{showPassword ? '👀' : '😝'}
|
||||
</button>
|
||||
<formContext.Provider value={form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isSubmitting)
|
||||
return
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="email" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('email', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<form.AppField name="email">
|
||||
{field => (
|
||||
<Input
|
||||
id="email"
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder={t('emailPlaceholder', { ns: 'login' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
{emailErrors && emailErrors.length > 0 && (
|
||||
<span className="text-sm text-red-400">
|
||||
{t(`${emailErrors[0]}` as 'error.emailInValid', { ns: 'login' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('mt-1 text-xs text-text-secondary', {
|
||||
'text-red-400 !text-sm': errors.password,
|
||||
})}
|
||||
>
|
||||
{t('error.passwordInvalid', { ns: 'login' })}
|
||||
<div className="mb-5">
|
||||
<label htmlFor="name" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('name', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<form.AppField name="name">
|
||||
{field => (
|
||||
<Input
|
||||
id="name"
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder={t('namePlaceholder', { ns: 'login' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
</div>
|
||||
{nameErrors && nameErrors.length > 0 && (
|
||||
<span className="text-sm text-red-400">
|
||||
{t(`${nameErrors[0]}` as 'error.nameEmpty', { ns: 'login' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="primary" className="w-full" onClick={handleSetting}>
|
||||
{t('installBtn', { ns: 'login' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mb-5">
|
||||
<label htmlFor="password" className="my-2 flex items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('password', { ns: 'login' })}
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<form.AppField name="password">
|
||||
{field => (
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder={t('passwordPlaceholder', { ns: 'login' }) || ''}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-text-quaternary hover:text-text-tertiary focus:text-text-tertiary focus:outline-none"
|
||||
>
|
||||
{showPassword ? '👀' : '😝'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('mt-1 text-xs text-text-secondary', {
|
||||
'text-red-400 !text-sm': passwordErrors && passwordErrors.length > 0,
|
||||
})}
|
||||
>
|
||||
{t('error.passwordInvalid', { ns: 'login' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="primary" type="submit" disabled={isSubmitting} loading={isSubmitting} className="w-full">
|
||||
{t('installBtn', { ns: 'login' })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</formContext.Provider>
|
||||
<div className="mt-2 block w-full text-xs text-text-secondary">
|
||||
{t('license.tip', { ns: 'login' })}
|
||||
|
||||
|
||||
<Link
|
||||
className="text-text-accent"
|
||||
target="_blank"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"blocks.datasource-empty": "Empty Data Source",
|
||||
"blocks.document-extractor": "Doc Extractor",
|
||||
"blocks.end": "Output",
|
||||
"blocks.group": "Group",
|
||||
"blocks.http-request": "HTTP Request",
|
||||
"blocks.if-else": "IF/ELSE",
|
||||
"blocks.iteration": "Iteration",
|
||||
@@ -38,7 +37,6 @@
|
||||
"blocksAbout.datasource-empty": "Empty Data Source placeholder",
|
||||
"blocksAbout.document-extractor": "Used to parse uploaded documents into text content that is easily understandable by LLM.",
|
||||
"blocksAbout.end": "Define the output and result type of a workflow",
|
||||
"blocksAbout.group": "Group multiple nodes together for better organization",
|
||||
"blocksAbout.http-request": "Allow server requests to be sent over the HTTP protocol",
|
||||
"blocksAbout.if-else": "Allows you to split the workflow into two branches based on if/else conditions",
|
||||
"blocksAbout.iteration": "Perform multiple steps on a list object until all results are outputted.",
|
||||
@@ -938,7 +936,6 @@
|
||||
"operator.distributeHorizontal": "Space Horizontally",
|
||||
"operator.distributeVertical": "Space Vertically",
|
||||
"operator.horizontal": "Horizontal",
|
||||
"operator.makeGroup": "Make Group",
|
||||
"operator.selectionAlignment": "Selection Alignment",
|
||||
"operator.vertical": "Vertical",
|
||||
"operator.zoomIn": "Zoom In",
|
||||
@@ -967,7 +964,6 @@
|
||||
"panel.scrollToSelectedNode": "Scroll to selected node",
|
||||
"panel.selectNextStep": "Select Next Step",
|
||||
"panel.startNode": "Start Node",
|
||||
"panel.ungroup": "Ungroup",
|
||||
"panel.userInputField": "User Input Field",
|
||||
"publishLimit.startNodeDesc": "You’ve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.",
|
||||
"publishLimit.startNodeTitlePrefix": "Upgrade to",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,6 @@
|
||||
"panel.scrollToSelectedNode": "選択したノードまでスクロール",
|
||||
"panel.selectNextStep": "次ノード選択",
|
||||
"panel.startNode": "開始ノード",
|
||||
"panel.ungroup": "グループ解除",
|
||||
"panel.userInputField": "ユーザー入力欄",
|
||||
"publishLimit.startNodeDesc": "このプランでは、各ワークフローのトリガー数は最大 2 個まで設定できます。公開するにはアップグレードが必要です。",
|
||||
"publishLimit.startNodeTitlePrefix": "アップグレードして、",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -936,7 +936,6 @@
|
||||
"operator.distributeHorizontal": "水平等间距",
|
||||
"operator.distributeVertical": "垂直等间距",
|
||||
"operator.horizontal": "水平方向",
|
||||
"operator.makeGroup": "建立群组",
|
||||
"operator.selectionAlignment": "选择对齐",
|
||||
"operator.vertical": "垂直方向",
|
||||
"operator.zoomIn": "放大",
|
||||
@@ -965,7 +964,6 @@
|
||||
"panel.scrollToSelectedNode": "滚动至选中节点",
|
||||
"panel.selectNextStep": "选择下一个节点",
|
||||
"panel.startNode": "开始节点",
|
||||
"panel.ungroup": "取消编组",
|
||||
"panel.userInputField": "用户输入字段",
|
||||
"publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。",
|
||||
"publishLimit.startNodeTitlePrefix": "升级以",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,6 @@
|
||||
"panel.scrollToSelectedNode": "捲動至選取的節點",
|
||||
"panel.selectNextStep": "選擇下一個節點",
|
||||
"panel.startNode": "起始節點",
|
||||
"panel.ungroup": "取消群組",
|
||||
"panel.userInputField": "用戶輸入字段",
|
||||
"publishLimit.startNodeDesc": "目前方案最多允許 2 個開始節點,升級後才能發布此工作流程。",
|
||||
"publishLimit.startNodeTitlePrefix": "升級以",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,6 @@
|
||||
"@formatjs/intl-localematcher": "^0.5.10",
|
||||
"@headlessui/react": "2.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lexical/code": "^0.38.2",
|
||||
"@lexical/link": "^0.38.2",
|
||||
"@lexical/list": "^0.38.2",
|
||||
@@ -123,7 +122,6 @@
|
||||
"react-18-input-autosize": "^3.0.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-easy-crop": "^5.5.3",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
|
||||
31
web/pnpm-lock.yaml
generated
31
web/pnpm-lock.yaml
generated
@@ -78,9 +78,6 @@ importers:
|
||||
'@heroicons/react':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(react@19.2.3)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.68.0(react@19.2.3))
|
||||
'@lexical/code':
|
||||
specifier: ^0.38.2
|
||||
version: 0.38.2
|
||||
@@ -267,9 +264,6 @@ importers:
|
||||
react-easy-crop:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react-hook-form:
|
||||
specifier: ^7.65.0
|
||||
version: 7.68.0(react@19.2.3)
|
||||
react-hotkeys-hook:
|
||||
specifier: ^4.6.2
|
||||
version: 4.6.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -1878,11 +1872,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16 || ^19.0.0-rc'
|
||||
|
||||
'@hookform/resolvers@5.2.2':
|
||||
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.55.0
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3211,9 +3200,6 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@storybook/addon-docs@9.1.13':
|
||||
resolution: {integrity: sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==}
|
||||
peerDependencies:
|
||||
@@ -7436,12 +7422,6 @@ packages:
|
||||
react-fast-compare@3.2.2:
|
||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||
|
||||
react-hook-form@7.68.0:
|
||||
resolution: {integrity: sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-hotkeys-hook@4.6.2:
|
||||
resolution: {integrity: sha512-FmP+ZriY3EG59Ug/lxNfrObCnW9xQShgk7Nb83+CkpfkcCpfS95ydv+E9JuXA5cp8KtskU7LGlIARpkc92X22Q==}
|
||||
peerDependencies:
|
||||
@@ -10516,11 +10496,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.68.0(react@19.2.3)
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
@@ -11782,8 +11757,6 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@storybook/addon-docs@9.1.13(@types/react@19.2.7)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.7)(react@19.2.3)
|
||||
@@ -16931,10 +16904,6 @@ snapshots:
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-hook-form@7.68.0(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
react-hotkeys-hook@4.6.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '@/models/app'
|
||||
import type { App, AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
@@ -107,6 +108,7 @@ export const useInfiniteAppList = (params: AppListParams, options?: { enabled?:
|
||||
queryFn: ({ pageParam = normalizedParams.page }) => get<AppListResponse>('/apps', { params: { ...normalizedParams, page: pageParam } }),
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: normalizedParams.page,
|
||||
placeholderData: keepPreviousData,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
18
web/service/use-dataset-card.ts
Normal file
18
web/service/use-dataset-card.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { checkIsUsedInApp, deleteDataset } from './datasets'
|
||||
|
||||
const NAME_SPACE = 'dataset-card'
|
||||
|
||||
export const useCheckDatasetUsage = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'check-usage'],
|
||||
mutationFn: (datasetId: string) => checkIsUsedInApp(datasetId),
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteDataset = () => {
|
||||
return useMutation({
|
||||
mutationKey: [NAME_SPACE, 'delete'],
|
||||
mutationFn: (datasetId: string) => deleteDataset(datasetId),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user