dify/web/features/deployments/create-guide/ui/source-step.tsx
Stephen Zhou 48452aefbc
feat: app deploy (#35670)
Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-17 09:28:43 +00:00

361 lines
12 KiB
TypeScript

'use client'
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
import {
continueFromSourceAtom,
dslFileAtom,
dslReadErrorAtom,
dslUnsupportedModeAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
methodAtom,
selectDslFileAtom,
selectMethodAtom,
selectSourceAppAtom,
setSourceSearchTextAtom,
sourceAppsQueryAtom,
sourceCanGoNextAtom,
sourceSearchTextAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
export function SourceStepContent() {
const method = useAtomValue(methodAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<SourceMethodSection />
{method === 'bindApp' && (
<SourceAppSelectionSection />
)}
{method === 'importDsl' && (
<DslUploadSection />
)}
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
</div>
)
}
function SourceMethodSection() {
const { t } = useTranslation('deployments')
const method = useAtomValue(methodAtom)
const selectMethod = useSetAtom(selectMethodAtom)
return (
<StepShell
title={t('createGuide.steps.method')}
description={t('createGuide.method.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<RadioGroup<GuideMethod>
value={method}
onValueChange={selectMethod}
className="flex flex-col items-stretch gap-2 sm:flex-row"
>
<SourceMethodCard
value="bindApp"
icon="i-ri-stack-line"
title={t('createGuide.methods.bindApp.title')}
description={t('createGuide.methods.bindApp.description')}
/>
<SourceMethodCard
value="importDsl"
icon="i-ri-file-code-line"
title={t('createGuide.methods.importDsl.title')}
description={t('createGuide.methods.importDsl.description')}
/>
</RadioGroup>
</StepShell>
)
}
function SourceMethodCard({ value, icon, title, description, badge }: {
value: GuideMethod
icon: string
title: string
description: string
badge?: string
}) {
return (
<RadioRoot<GuideMethod>
value={value}
variant="unstyled"
className={cn(
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
focus-visible:ring-state-accent-solid sm:w-[240px]`,
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<TitleTooltip content={description}>
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
{description}
</span>
</TitleTooltip>
</span>
</RadioRoot>
)
}
function SourceAppSelectionSection() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.source.title')}
description={t('createGuide.source.description')}
descriptionClassName="lg:hidden"
hideHeader
className="min-h-0 flex-1"
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<SourceSearchInput />
<SourceAppList />
</div>
</StepShell>
)
}
function SourceSearchInput() {
const { t } = useTranslation('deployments')
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
return (
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
<Input
id="create-guide-source-search"
aria-label={t('createGuide.source.sourceApp')}
value={sourceSearchText}
onChange={event => setSourceSearchText(event.target.value)}
placeholder={t('createGuide.source.searchPlaceholder')}
className="h-9 pr-8 pl-8"
/>
{sourceSearchText && (
<button
type="button"
aria-label={t('createGuide.source.clearSearch')}
onClick={() => setSourceSearchText('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
)
}
function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
return (
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
{sourceAppsLoading
? <SourceAppSkeleton />
: sourceApps.length === 0
? (
<DeploymentStateMessage variant="embedded">
{t('createGuide.source.empty')}
</DeploymentStateMessage>
)
: (
<div>
{sourceApps.map(app => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedApp?.id === app.id}
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsQuery.hasNextPage && (
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
<Button
type="button"
size="small"
disabled={sourceAppsQuery.isFetchingNextPage}
onClick={() => {
void sourceAppsQuery.fetchNextPage()
}}
>
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
)}
</div>
)
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({ app, onSelect, selected }: {
app: WorkflowSourceApp
onSelect: () => void
selected: boolean
}) {
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'bg-state-accent-hover hover:bg-state-accent-hover'
: 'bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow">
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}
function DslUploadSection() {
const { t } = useTranslation('deployments')
const dslFile = useAtomValue(dslFileAtom)
const selectDslFile = useSetAtom(selectDslFileAtom)
return (
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
</div>
</div>
<Uploader
className="mt-0"
file={dslFile}
updateFile={selectDslFile}
/>
<DslReadStatus />
</div>
</StepShell>
)
}
function DslReadStatus() {
const { t } = useTranslation('deployments')
const isReadingDsl = useAtomValue(isReadingDslAtom)
const dslReadError = useAtomValue(dslReadErrorAtom)
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
return (
<>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('createGuide.dsl.reading')}
</div>
)}
{dslReadError && (
<div className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.readFailed')}
</div>
)}
{dslUnsupportedMode && (
<div role="alert" className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.unsupportedMode')}
</div>
)}
</>
)
}
export function SourceActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(sourceCanGoNextAtom)
const continueFromSource = useSetAtom(continueFromSourceAtom)
return (
<Button
type="button"
variant="primary"
disabled={!canGoNext}
onClick={() => continueFromSource({
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
defaultReleaseName: t('createGuide.release.defaultName'),
})}
>
{t('createGuide.actions.next')}
</Button>
)
}