diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 2dcc0d7fc2d..53abd3adf37 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -25,6 +25,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc - Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. - Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices. - Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them. +- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README. +- Module README whitelist: `@/service/client`, `@/next/*`. - Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities. - Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly. - Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators. diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx index f8db21c90d1..eff309f8e85 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/access/page.tsx @@ -1,5 +1,5 @@ -import { AccessTab } from '@/features/deployments/detail/access-tab' +import { DeploymentAccess } from '@/features/deployments/detail/access' export default function InstanceDetailAccessPage() { - return + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx index 55d4812aa4c..1adba300386 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/api-tokens/page.tsx @@ -1,5 +1,5 @@ -import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api' +import { DeploymentApiTokens } from '@/features/deployments/detail/api-tokens' export default function InstanceDetailApiTokensPage() { - return + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx index a8cd6e93c6d..0c4cbafc18e 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/instances/page.tsx @@ -1,5 +1,5 @@ -import { DeployTab } from '@/features/deployments/detail/deploy-tab' +import { DeploymentInstances } from '@/features/deployments/detail/instances' export default function InstanceDetailInstancesPage() { - return + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx index 4683811a997..2ba3deb2202 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/overview/page.tsx @@ -1,5 +1,5 @@ -import { OverviewTab } from '@/features/deployments/detail/overview-tab' +import { DeploymentOverview } from '@/features/deployments/detail/overview' export default function InstanceDetailOverviewPage() { - return + return } diff --git a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx index 3d77a24ae55..29c928a8a8b 100644 --- a/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx +++ b/web/app/(commonLayout)/deployments/[appInstanceId]/releases/page.tsx @@ -1,5 +1,5 @@ -import { VersionsTab } from '@/features/deployments/detail/versions-tab' +import { DeploymentReleases } from '@/features/deployments/detail/releases' export default function InstanceDetailReleasesPage() { - return + return } diff --git a/web/app/components/next-route-state/atoms.ts b/web/app/components/next-route-state/atoms.ts index ec8de36aeb4..d319defa5a1 100644 --- a/web/app/components/next-route-state/atoms.ts +++ b/web/app/components/next-route-state/atoms.ts @@ -32,6 +32,7 @@ function routeParamsKey(params: NextRouteParams) { } export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params) +export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname) export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => { const nextParams = normalizeNextRouteParams(routeState.params) diff --git a/web/features/deployments/README.md b/web/features/deployments/README.md new file mode 100644 index 00000000000..c226bfb2fdb --- /dev/null +++ b/web/features/deployments/README.md @@ -0,0 +1,101 @@ +# Deployments + +Deployment app instance, release, runtime target, access, and developer API feature modules. + +## Internal Module Dependency Graph + +The graph shows product module dependencies inside `web/features/deployments`. It omits route-state plumbing and shared support modules. + +```mermaid +flowchart TD + list["list"] + detail["detail"] + createGuide["create-guide"] + createRelease["create-release"] + deployDrawer["deploy-drawer"] + deploymentActions["deployment-actions"] + + detailOverview["detail/overview"] + detailInstances["detail/instances"] + detailAccess["detail/access"] + detailApiTokens["detail/api-tokens"] + detailReleases["detail/releases"] + + accessChannels["detail/access/channels"] + accessPermissions["detail/access/permissions"] + accessSubjectSelector["detail/access/permissions/access-subject-selector"] + apiTokenManagement["detail/api-tokens/api-token-management"] + apiKeys["detail/api-tokens/api-keys"] + apiDocs["detail/api-tokens/docs"] + instanceEnvironmentList["detail/instances/environment-list"] + instanceHeaderActions["detail/instances/header-actions"] + instanceRowActions["detail/instances/row-actions"] + overviewAccessSummary["detail/overview/access-summary"] + overviewEnvironmentStatus["detail/overview/environment-status"] + overviewReleaseSummary["detail/overview/release-summary"] + releaseActions["detail/releases/release-actions"] + releaseHistory["detail/releases/release-history"] + + list --> createGuide + list --> createRelease + list --> deployDrawer + list --> deploymentActions + list --> detail + + detail --> createRelease + detail --> deploymentActions + detail --> detailOverview + detail --> detailInstances + detail --> detailAccess + detail --> detailApiTokens + detail --> detailReleases + detail --> instanceHeaderActions + + detailOverview --> overviewAccessSummary + detailOverview --> overviewEnvironmentStatus + detailOverview --> overviewReleaseSummary + + overviewEnvironmentStatus --> deployDrawer + overviewReleaseSummary --> createRelease + + detailInstances --> detail + detailInstances --> instanceEnvironmentList + detailInstances --> instanceHeaderActions + instanceEnvironmentList --> detailInstances + instanceEnvironmentList --> instanceRowActions + instanceHeaderActions --> deployDrawer + instanceHeaderActions --> detail + instanceRowActions --> deployDrawer + + detailAccess --> accessChannels + detailAccess --> accessPermissions + accessChannels --> detailAccess + accessPermissions --> detailAccess + accessPermissions --> accessSubjectSelector + + detailApiTokens --> apiTokenManagement + apiTokenManagement --> detailApiTokens + apiTokenManagement --> apiKeys + apiTokenManagement --> apiDocs + apiKeys --> detailApiTokens + + detailReleases --> releaseHistory + releaseActions --> deployDrawer + releaseHistory --> detailReleases + releaseHistory --> releaseActions +``` + +## Internal Modules + +| Module | Why this module uses it | +| -------------------- | ------------------------------------------------------------- | +| `list` | Owns the deployment app instance list surface. | +| `detail` | Owns the deployment app instance detail shell and route tabs. | +| `create-guide` | Owns the create deployment guide workflow. | +| `create-release` | Owns release creation entry points and dialog state. | +| `deploy-drawer` | Owns deployment target selection and submit workflow state. | +| `deployment-actions` | Owns app instance edit and delete action surfaces. | + +## External Modules + +None. diff --git a/web/features/deployments/components/deployment-actions/state.ts b/web/features/deployments/components/deployment-actions/state.ts deleted file mode 100644 index 113492beb24..00000000000 --- a/web/features/deployments/components/deployment-actions/state.ts +++ /dev/null @@ -1,42 +0,0 @@ -'use client' - -import { atom } from 'jotai' -import { atomWithQuery } from 'jotai-tanstack-query' -import { atomWithLazy } from 'jotai/utils' -import { consoleQuery } from '@/service/client' - -export const deploymentActionAppInstanceIdAtom = atomWithLazy(() => { - throw new Error('Missing deployment action app instance id.') -}) - -export const editDeploymentDialogOpenAtom = atom(false) -export const deleteDeploymentDialogOpenAtom = atom(false) - -export const deploymentActionAppInstanceQueryOptionsAtom = atom((get) => { - const appInstanceId = get(deploymentActionAppInstanceIdAtom) - - return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({ - input: { - params: { appInstanceId }, - }, - }) -}) - -export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => { - return get(deploymentActionAppInstanceQueryOptionsAtom) -}) - -export const openEditDeploymentDialogAtom = atom(null, (_get, set) => { - set(deleteDeploymentDialogOpenAtom, false) - set(editDeploymentDialogOpenAtom, true) -}) - -export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => { - set(editDeploymentDialogOpenAtom, false) - set(deleteDeploymentDialogOpenAtom, true) -}) - -export const deploymentActionsLocalAtoms = [ - editDeploymentDialogOpenAtom, - deleteDeploymentDialogOpenAtom, -] as const diff --git a/web/features/deployments/create-guide/README.md b/web/features/deployments/create-guide/README.md new file mode 100644 index 00000000000..bdc24c614d1 --- /dev/null +++ b/web/features/deployments/create-guide/README.md @@ -0,0 +1,18 @@ +# Deployment Create Guide + +Guided deployment creation workflow for selecting a source, defining the initial release, and optionally deploying to an environment. + +## Internal Modules + +| Module | Why this module uses it | +| -------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | + +## External Modules + +| Module | Why this module uses it | +| --------------------------------------------------- | -------------------------------------------------------------------------------- | +| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the import source step. | +| `app/components/base/app-icon` | Renders app icons consistently in source app options. | +| `app/components/base/skeleton` | Reuses the existing skeleton primitive for source and target loading states. | +| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. | diff --git a/web/features/deployments/create-guide/link.tsx b/web/features/deployments/create-guide/link.tsx new file mode 100644 index 00000000000..ae058f9e25a --- /dev/null +++ b/web/features/deployments/create-guide/link.tsx @@ -0,0 +1,17 @@ +'use client' + +import type { ComponentProps } from 'react' +import Link from '@/next/link' + +const createDeploymentGuideHref = '/deployments/create' + +type CreateDeploymentGuideLinkProps = Omit, 'href'> + +export function CreateDeploymentGuideLink(props: CreateDeploymentGuideLinkProps) { + return ( + + ) +} diff --git a/web/features/deployments/create-guide/state/__tests__/index.spec.ts b/web/features/deployments/create-guide/state/__tests__/index.spec.ts index bd674b9c126..3a05d665e11 100644 --- a/web/features/deployments/create-guide/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-guide/state/__tests__/index.spec.ts @@ -1,6 +1,27 @@ import type { Getter } from 'jotai' import { atom, createStore } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + dslFileAtom, + effectiveMethodAtom, + instanceNameAtom, + methodAtom, + releaseNameAtom, + selectedAppAtom, + stepAtom, +} from '../primitives' +import { + dslDefaultAppNameAtom, + dslReadErrorAtom, + isReadingDslAtom, + sourceAppsQueryAtom, +} from '../source' +import { + continueFromSourceAtom, + selectDslFileAtom, + selectMethodAtom, + sourceCanGoNextAtom, +} from '../workflow' type QueryOptions = { enabled?: boolean @@ -119,10 +140,6 @@ vi.mock('@/service/client', () => ({ }, })) -async function loadState() { - return await import('../index') -} - function workflowDsl() { return [ 'app:', @@ -137,30 +154,27 @@ describe('create deployment guide state', () => { mockQueryResults.current.clear() }) - it('should keep the guide on source app mode when DSL import is disabled', async () => { - const state = await loadState() + it('should keep the guide on source app mode when DSL import is disabled', () => { const store = createStore() - store.set(state.selectMethodAtom, 'importDsl') + store.set(selectMethodAtom, 'importDsl') - expect(store.get(state.methodAtom)).toBe('bindApp') - expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + expect(store.get(methodAtom)).toBe('bindApp') + expect(store.get(effectiveMethodAtom)).toBe('bindApp') }) - it('should keep source app loading enabled if stale state points to DSL import', async () => { - const state = await loadState() + it('should keep source app loading enabled if stale state points to DSL import', () => { const store = createStore() - store.set(state.methodAtom, 'importDsl') + store.set(methodAtom, 'importDsl') - const sourceAppsQuery = store.get(state.sourceAppsQueryAtom) as unknown as { enabled?: boolean } + const sourceAppsQuery = store.get(sourceAppsQueryAtom) as unknown as { enabled?: boolean } - expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + expect(store.get(effectiveMethodAtom)).toBe('bindApp') expect(sourceAppsQuery.enabled).toBe(true) }) - it('should continue from source app mode and auto-fill unique release metadata', async () => { - const state = await loadState() + it('should continue from source app mode and auto-fill unique release metadata', () => { const store = createStore() mockQueryResults.current.set('sourceApps', { data: { @@ -208,39 +222,38 @@ describe('create deployment guide state', () => { isSuccess: true, }) - expect(store.get(state.sourceCanGoNextAtom)).toBe(true) + expect(store.get(sourceCanGoNextAtom)).toBe(true) - store.set(state.continueFromSourceAtom, { + store.set(continueFromSourceAtom, { defaultDslAppName: 'Imported DSL', defaultReleaseName: 'Initial Release', }) - expect(store.get(state.selectedAppAtom)).toMatchObject({ + expect(store.get(selectedAppAtom)).toMatchObject({ id: 'source-app-1', name: 'Customer Service', }) - expect(store.get(state.instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/) - expect(store.get(state.releaseNameAtom)).toBe('Initial Release') - expect(store.get(state.stepAtom)).toBe('release') + expect(store.get(instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/) + expect(store.get(releaseNameAtom)).toBe('Initial Release') + expect(store.get(stepAtom)).toBe('release') }) - it('should read selected DSL file content through the file content query', async () => { - const state = await loadState() + it('should read selected DSL file content through the file content query', () => { const store = createStore() const text = vi.fn().mockResolvedValue(workflowDsl()) const file = new File([], 'workflow.yml', { type: 'text/yaml' }) Object.defineProperty(file, 'text', { value: text }) - store.set(state.selectDslFileAtom, file) + store.set(selectDslFileAtom, file) mockQueryResults.current.set('createGuideDslFileContent', { data: workflowDsl(), isSuccess: true, }) expect(text).not.toHaveBeenCalled() - expect(store.get(state.dslFileAtom)).toBe(file) - expect(store.get(state.dslDefaultAppNameAtom)).toBe('Imported guide') - expect(store.get(state.isReadingDslAtom)).toBe(false) - expect(store.get(state.dslReadErrorAtom)).toBe(false) + expect(store.get(dslFileAtom)).toBe(file) + expect(store.get(dslDefaultAppNameAtom)).toBe('Imported guide') + expect(store.get(isReadingDslAtom)).toBe(false) + expect(store.get(dslReadErrorAtom)).toBe(false) }) }) diff --git a/web/features/deployments/create-guide/state/index.ts b/web/features/deployments/create-guide/state/index.ts deleted file mode 100644 index f5665e95846..00000000000 --- a/web/features/deployments/create-guide/state/index.ts +++ /dev/null @@ -1,933 +0,0 @@ -'use client' - -import type { - DeployRequest, - EnvVarInput, -} from '@dify/contracts/enterprise/types.gen' -import type { Getter } from 'jotai/vanilla' -import type { EnvVarBindingSlot, EnvVarValues, EnvVarValueSelection } from '@/features/deployments/components/env-var-bindings' -import type { RuntimeCredentialBindingSelections } from '@/features/deployments/components/runtime-credential-bindings-utils' -import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error' -import type { App } from '@/types/app' -import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' -import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query' -import { atom } from 'jotai' -import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' -import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/components/env-var-bindings-utils' -import { - hasMissingRequiredRuntimeCredentialBinding, - runtimeCredentialSlotKey, - selectedDeploymentRuntimeCredentials, - selectedRuntimeCredentialSelections, -} from '@/features/deployments/components/runtime-credential-bindings-utils' -import { - dslAppName, - dslEnvVarSlots, - encodeDslContent, - isWorkflowDsl, -} from '@/features/deployments/shared/domain/dsl' -import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error' -import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' -import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency' -import { - DEPLOYMENT_PAGE_SIZE, - getNextPageParamFromPagination, - SOURCE_APPS_PAGE_SIZE, -} from '@/features/deployments/shared/domain/pagination' -import { consoleQuery } from '@/service/client' -import { AppModeEnum } from '@/types/app' -import { environmentMatchesIdentifier } from './environment' - -export type GuideMethod = 'bindApp' | 'importDsl' -export type GuideStep = 'source' | 'release' | 'target' -export type WorkflowSourceApp = App & { mode: Extract } - -function deploymentGuideMethod(method: GuideMethod): GuideMethod { - return method === 'importDsl' && !isDeploymentDslImportEnabled - ? 'bindApp' - : method -} - -const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' -const RANDOM_SUFFIX_LENGTH = 4 -const RANDOM_SUFFIX_FALLBACK_LENGTH = 6 -const RANDOM_SUFFIX_MAX_ATTEMPTS = 16 - -function randomLetterCombination(length: number) { - const randomValues = new Uint8Array(length) - - if (globalThis.crypto) { - globalThis.crypto.getRandomValues(randomValues) - } - else { - randomValues.forEach((_, index) => { - randomValues[index] = Math.floor(Math.random() * 256) - }) - } - - return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('') -} - -function availableInstanceName(sourceName: string, existingNameSet: Set) { - if (!existingNameSet.has(sourceName)) - return sourceName - - for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) { - const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}` - if (!existingNameSet.has(candidate)) - return candidate - } - - return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}` -} - -function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { - return selection?.valueSource - ?? (slot.hasDefaultValue - ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT - : slot.hasLastValue - ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT - : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL) -} - -function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { - const valueSource = envVarValueSource(slot, selection) - - if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) - return Boolean(slot.hasLastValue) - if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) - return Boolean(slot.hasDefaultValue) - if (!selection?.value) - return false - - return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value)) -} - -function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] { - const valueSource = envVarValueSource(slot, selection) - - if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) { - return slot.hasLastValue - ? [{ key: slot.key, valueSource }] - : [] - } - - if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) { - return slot.hasDefaultValue - ? [{ key: slot.key, valueSource }] - : [] - } - - if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value)))) - return [] - - return [{ - key: slot.key, - value: selection.value, - valueSource, - }] -} - -// Workflow primitives -export const stepAtom = atom('source') -export const methodAtom = atom('bindApp') -export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom))) - -// Source primitives -export const sourceSearchTextAtom = atom('') -export const selectedAppAtom = atom(undefined) - -// DSL primitives and derived state -export const dslFileAtom = atom(undefined) -const dslFileReadVersionAtom = atom(0) - -const dslFileContentQueryAtom = atomWithQuery((get) => { - const file = get(dslFileAtom) - const fileReadVersion = get(dslFileReadVersionAtom) - - return queryOptions({ - queryKey: [ - 'createGuideDslFileContent', - fileReadVersion, - file, - file?.name ?? '', - file?.size ?? 0, - file?.lastModified ?? 0, - ], - queryFn: async () => file ? await file.text() : '', - enabled: Boolean(file), - retry: false, - }) -}) - -const dslContentAtom = atom((get) => { - return get(dslFileContentQueryAtom).data ?? '' -}) - -export const isReadingDslAtom = atom((get) => { - const file = get(dslFileAtom) - const dslFileContentQuery = get(dslFileContentQueryAtom) - - return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching)) -}) - -export const dslReadErrorAtom = atom((get) => { - return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError) -}) - -export const dslDefaultAppNameAtom = atom((get) => { - const dslContent = get(dslContentAtom) - - return dslContent ? dslAppName(dslContent) : '' -}) - -export const dslUnsupportedModeAtom = atom((get) => { - const dslContent = get(dslContentAtom) - - return get(effectiveMethodAtom) === 'importDsl' - && Boolean(dslContent.trim()) - && !get(isReadingDslAtom) - && !get(dslReadErrorAtom) - && !isWorkflowDsl(dslContent) -}) - -const importDslReadyAtom = atom((get) => { - return Boolean(get(dslContentAtom).trim()) - && !get(isReadingDslAtom) - && !get(dslReadErrorAtom) - && !get(dslUnsupportedModeAtom) -}) - -// Release primitives -export const instanceNameAtom = atom('') -export const instanceDescriptionAtom = atom('') -export const releaseNameAtom = atom('') -export const releaseDescriptionAtom = atom('') -const autoFilledInstanceNameAtom = atom('') -const autoFilledReleaseNameAtom = atom('') - -// Target primitives -export const selectedEnvironmentIdAtom = atom('') -const manualBindingSelectionsAtom = atom({}) -export const envVarValuesAtom = atom({}) - -// Submission primitives -const submissionUnsupportedDslNodesAtom = atom([]) -const isCreatingDeploymentAtom = atom(false) -export const isCreatingReleaseOnlyAtom = atom(false) - -export const isSubmittingDeploymentGuideAtom = atom(get => ( - get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom) -)) - -// Query and remote data -export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { - const sourceSearchText = get(sourceSearchTextAtom) - - return consoleQuery.apps.list.infiniteOptions({ - input: pageParam => ({ - query: { - page: Number(pageParam), - limit: SOURCE_APPS_PAGE_SIZE, - name: sourceSearchText, - mode: AppModeEnum.WORKFLOW, - }, - }), - getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, - initialPageParam: 1, - placeholderData: keepPreviousData, - enabled: get(effectiveMethodAtom) === 'bindApp', - }) -}) - -export const effectiveSelectedAppAtom = atom((get) => { - const selectedApp = get(selectedAppAtom) - if (selectedApp) - return selectedApp - - const sourceAppsQuery = get(sourceAppsQueryAtom) - if (sourceAppsQuery.isPlaceholderData) - return undefined - - const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[] - - return sourceApps[0] -}) - -function sourceReady(get: Getter) { - const method = get(effectiveMethodAtom) - - return method === 'importDsl' - ? get(importDslReadyAtom) - : Boolean(get(effectiveSelectedAppAtom)?.id) -} - -const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() => - consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({ - input: pageParam => ({ - query: { - pageNumber: Number(pageParam), - resultsPerPage: DEPLOYMENT_PAGE_SIZE, - }, - }), - getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination), - initialPageParam: 1, - placeholderData: keepPreviousData, - }), -) - -const instanceNameConflictQueryAtom = atomWithQuery((get) => { - const submittedInstanceName = get(instanceNameAtom).trim() - - return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({ - input: { - query: { - pageNumber: 1, - resultsPerPage: 1, - displayName: submittedInstanceName, - }, - }, - enabled: Boolean(submittedInstanceName), - }) -}) - -export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => { - return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ - input: { - query: { - // The guide offers every deployable environment at once; environment - // count is capped well below the 100-per-page maximum. - pageNumber: 1, - resultsPerPage: 100, - }, - }, - enabled: sourceReady(get), - }) -}) - -const precheckReleaseQueryAtom = atomWithQuery((get) => { - const method = get(effectiveMethodAtom) - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - const dslContent = get(dslContentAtom) - const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined - const enabled = sourceReady(get) - - // PrecheckRelease takes exactly one source arm (dsl | sourceAppId). - const precheckReleaseQueryOptions = method === 'importDsl' - ? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: encodedDslContent - ? { - body: { - dsl: encodedDslContent, - }, - } - : skipToken, - enabled, - retry: false, - }) - : consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: effectiveSelectedApp?.id - ? { - body: { - sourceAppId: effectiveSelectedApp.id, - }, - } - : skipToken, - enabled: enabled && Boolean(effectiveSelectedApp?.id), - retry: false, - }) - - return precheckReleaseQueryOptions -}) - -function precheckReleaseReady(get: Getter) { - const precheckReleaseQuery = get(precheckReleaseQueryAtom) - - return sourceReady(get) - && precheckReleaseQuery.isSuccess - && Boolean(precheckReleaseQuery.data?.canCreate) - && (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0 - && get(submissionUnsupportedDslNodesAtom).length === 0 -} - -export const deploymentOptionsQueryAtom = atomWithQuery((get) => { - const method = get(effectiveMethodAtom) - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - const dslContent = get(dslContentAtom) - const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined - const enabled = precheckReleaseReady(get) - - // ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId). - const deploymentOptionsQueryOptions = method === 'importDsl' - ? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ - input: encodedDslContent - ? { - body: { - dsl: encodedDslContent, - }, - } - : skipToken, - enabled, - retry: false, - }) - : consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ - input: effectiveSelectedApp?.id - ? { - body: { - sourceAppId: effectiveSelectedApp.id, - }, - } - : skipToken, - enabled: enabled && Boolean(effectiveSelectedApp?.id), - retry: false, - }) - - return deploymentOptionsQueryOptions -}) - -// Unsupported DSL state -export const unsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => { - const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom) - if (submissionUnsupportedDslNodes.length > 0) - return submissionUnsupportedDslNodes - - if (!sourceReady(get)) - return [] - - return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? [] -}) - -const precheckReleaseReadyAtom = atom((get) => { - return precheckReleaseReady(get) -}) - -const deploymentOptionsReadyAtom = atom((get) => { - const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) - - return sourceReady(get) - && get(precheckReleaseReadyAtom) - && deploymentOptionsQuery.isSuccess -}) - -const deploymentOptionsContentCheckedAtom = atom((get) => { - const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) - const precheckReleaseQuery = get(precheckReleaseQueryAtom) - const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data) - const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data) - - if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions) - return false - - return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess -}) - -export const sourceCanGoNextAtom = atom((get) => { - const method = get(effectiveMethodAtom) - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - const importDslReady = method === 'importDsl' && get(importDslReadyAtom) - const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) - - return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom) -}) - -export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => { - if (get(sourceSearchTextAtom) === value) - return - - set(sourceSearchTextAtom, value) - set(selectedAppAtom, undefined) - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(submissionUnsupportedDslNodesAtom, []) -}) - -export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => { - set(selectedAppAtom, app) - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(submissionUnsupportedDslNodesAtom, []) -}) - -export const continueFromSourceAtom = atom(null, (get, set, { - defaultDslAppName, - defaultReleaseName, -}: { - defaultDslAppName: string - defaultReleaseName: string -}) => { - if (!get(sourceCanGoNextAtom)) - return - - const method = get(effectiveMethodAtom) - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - if (method === 'bindApp' && effectiveSelectedApp) - set(selectSourceAppAtom, effectiveSelectedApp) - - const sourceName = method === 'importDsl' - ? get(dslDefaultAppNameAtom) || defaultDslAppName - : effectiveSelectedApp?.name - const nextInstanceName = sourceName?.trim() - - if (nextInstanceName) { - const currentInstanceName = get(instanceNameAtom).trim() - const autoFilledInstanceName = get(autoFilledInstanceNameAtom) - const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) - const existingNameSet = new Set( - existingInstanceNamesQuery.data?.pages.flatMap(page => - page.appInstances.flatMap((appInstance) => { - const name = appInstance.displayName.trim() - - return name ? [name] : [] - }), - ) ?? [], - ) - - if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) { - const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet) - set(instanceNameAtom, nextAvailableInstanceName) - set(autoFilledInstanceNameAtom, nextAvailableInstanceName) - } - } - - const currentReleaseName = get(releaseNameAtom).trim() - const autoFilledReleaseName = get(autoFilledReleaseNameAtom) - if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) { - set(releaseNameAtom, defaultReleaseName) - set(autoFilledReleaseNameAtom, defaultReleaseName) - } - set(stepAtom, 'release') -}) - -// DSL actions -export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => { - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(submissionUnsupportedDslNodesAtom, []) - - set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1) - set(dslFileAtom, dslFile) -}) - -// Release derived state and actions -export const hasInstanceNameConflictAtom = atom((get) => { - const submittedInstanceName = get(instanceNameAtom).trim() - const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) - const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) - const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page => - page.appInstances.flatMap((appInstance) => { - const name = appInstance.displayName.trim() - - return name ? [name] : [] - }), - ) ?? [] - - return Boolean( - submittedInstanceName - && ( - existingInstanceNames.includes(submittedInstanceName) - || (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false) - ), - ) -}) - -const submittedReleaseReadyAtom = atom((get) => { - return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim()) -}) - -export const releaseCanGoNextAtom = atom((get) => { - const submittedInstanceName = get(instanceNameAtom).trim() - const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) - - return Boolean(get(submittedReleaseReadyAtom)) - && !get(hasInstanceNameConflictAtom) - && !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading) - && get(deploymentOptionsContentCheckedAtom) -}) - -export const setInstanceNameAtom = atom(null, (_get, set, value: string) => { - set(instanceNameAtom, value) - set(autoFilledInstanceNameAtom, '') - set(stepAtom, 'release') -}) - -export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => { - set(instanceDescriptionAtom, value) - set(stepAtom, 'release') -}) - -export const setReleaseNameAtom = atom(null, (_get, set, value: string) => { - set(releaseNameAtom, value) - set(autoFilledReleaseNameAtom, '') - set(stepAtom, 'release') -}) - -export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => { - set(releaseDescriptionAtom, value) - set(stepAtom, 'release') -}) - -export const continueFromReleaseAtom = atom(null, (get, set) => { - if (!get(releaseCanGoNextAtom)) - return - - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(stepAtom, 'target') -}) - -// Target derived state and actions -export const deployableEnvironmentsAtom = atom((get) => { - const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) - - return sourceReady(get) - ? deployableEnvironmentsQuery.data?.environments ?? [] - : [] -}) - -const deployableEnvironmentsReadyAtom = atom((get) => { - const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) - - return sourceReady(get) && deployableEnvironmentsQuery.isSuccess -}) - -export const effectiveSelectedEnvironmentIdAtom = atom((get) => { - return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id -}) - -export const deploymentTargetBindingSlotsAtom = atom((get) => { - const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) - - return sourceReady(get) - ? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? [] - : [] -}) - -export const deploymentTargetBindingSelectionsAtom = atom((get) => { - return selectedRuntimeCredentialSelections( - get(deploymentTargetBindingSlotsAtom), - get(manualBindingSelectionsAtom), - ) -}) - -const requiredBindingsReadyAtom = atom((get) => { - const bindingSelections = get(deploymentTargetBindingSelectionsAtom) - - return get(deploymentTargetBindingSlotsAtom).every(slot => - !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]), - ) -}) - -export const deploymentTargetEnvVarSlotsAtom = atom((get) => { - const method = get(effectiveMethodAtom) - const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) - const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined - const dslContent = get(dslContentAtom) - - // Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults. - const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => { - const bindingSlot = envVarBindingSlotFromContract(slot) - return bindingSlot ? [bindingSlot] : [] - }) ?? [] - const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent - ? dslEnvVarSlots(dslContent).flatMap((slot) => { - const key = slot.key.trim() - if (!key) - return [] - - return [{ - key, - ...(slot.description ? { description: slot.description } : {}), - ...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}), - ...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}), - }] - }) - : [] - - if (dslEnvVarMetadataSlots.length === 0) - return deploymentOptionEnvVarSlots - - const metadataByKey = new Map( - dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const), - ) - - return deploymentOptionEnvVarSlots.map((slot) => { - const metadata = metadataByKey.get(slot.key) - if (!metadata) - return slot - - const nextSlot = { ...slot } - - if (!nextSlot.description && metadata.description) - nextSlot.description = metadata.description - if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) { - nextSlot.defaultValue = metadata.defaultValue - nextSlot.hasDefaultValue = true - } - if (nextSlot.valueType === 'string' && metadata.valueType) - nextSlot.valueType = metadata.valueType - - return nextSlot - }) -}) - -const requiredEnvVarsReadyAtom = atom((get) => { - const envVarValues = get(envVarValuesAtom) - - return get(deploymentTargetEnvVarSlotsAtom).every(slot => - envVarSelectionReady(slot, envVarValues[slot.key]), - ) -}) - -export const canDeployAtom = atom((get) => { - const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom) - const selectedEnvironment = effectiveSelectedEnvironmentId - ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) - : undefined - - return Boolean( - selectedEnvironment?.id - && get(deployableEnvironmentsReadyAtom) - && get(deploymentOptionsReadyAtom) - && get(requiredBindingsReadyAtom) - && get(requiredEnvVarsReadyAtom) - && get(submittedReleaseReadyAtom), - ) -}) - -export const canSkipDeploymentAtom = atom((get) => { - return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom) -}) - -export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => { - set(manualBindingSelectionsAtom, { - ...get(manualBindingSelectionsAtom), - [slot]: value, - }) -}) - -export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => { - set(envVarValuesAtom, { - ...get(envVarValuesAtom), - [key]: value, - }) -}) - -// Workflow actions -export const selectMethodAtom = atom(null, (_get, set, method: GuideMethod) => { - set(methodAtom, deploymentGuideMethod(method)) - set(selectedEnvironmentIdAtom, '') - set(manualBindingSelectionsAtom, {}) - set(envVarValuesAtom, {}) - set(submissionUnsupportedDslNodesAtom, []) - set(stepAtom, 'source') -}) - -// Submission -const createAppInstanceMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(), -) - -const createReleaseMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.releaseService.createRelease.mutationOptions(), -) - -const createInitialDeploymentMutationAtom = atomWithMutation(() => - consoleQuery.enterprise.deploymentService.deploy.mutationOptions(), -) - -export class CreateDeploymentGuideSubmissionBlockedError extends Error { - reason: 'unsupportedDslMode' | 'deployFailed' - - constructor(reason: 'unsupportedDslMode' | 'deployFailed') { - super(reason) - this.reason = reason - this.name = 'CreateDeploymentGuideSubmissionBlockedError' - } -} - -export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { - deployToEnvironment, -}: { - deployToEnvironment: boolean -}) => { - const method = get(effectiveMethodAtom) - const dslContent = get(dslContentAtom) - const submittedInstanceName = get(instanceNameAtom).trim() - const submittedReleaseName = get(releaseNameAtom).trim() - const submittedReleaseDescription = get(releaseDescriptionAtom).trim() - - if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom)) - return undefined - - const effectiveSelectedApp = get(effectiveSelectedAppAtom) - const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) - const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options - const envVarSlots = get(deploymentTargetEnvVarSlotsAtom) - const envVarValues = get(envVarValuesAtom) - const bindingSlots = get(deploymentTargetBindingSlotsAtom) - const bindingSelections = get(deploymentTargetBindingSelectionsAtom) - const selectedEnvironmentId = get(selectedEnvironmentIdAtom) - const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id - const selectedEnvironment = effectiveSelectedEnvironmentId - ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) - : undefined - - if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim()) - return undefined - if (method === 'bindApp' && !effectiveSelectedApp?.id) - return undefined - if (method === 'importDsl' && !dslContent.trim()) - return undefined - if (method === 'importDsl' && !isWorkflowDsl(dslContent)) - throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode') - - set(submissionUnsupportedDslNodesAtom, []) - - try { - if (!deployToEnvironment) { - if (!get(canSkipDeploymentAtom)) - return undefined - - set(isCreatingReleaseOnlyAtom, true) - - try { - const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({ - body: { - displayName: submittedInstanceName, - description: get(instanceDescriptionAtom).trim() || undefined, - }, - }) - const appInstanceId = createdAppInstance.appInstance.id - - if (method === 'importDsl') { - await get(createReleaseMutationAtom).mutateAsync({ - body: { - appInstanceId, - dsl: encodeDslContent(dslContent), - displayName: submittedReleaseName, - description: submittedReleaseDescription || undefined, - createAppInstance: false, - }, - }) - - return appInstanceId - } - - if (!effectiveSelectedApp?.id) - return undefined - - await get(createReleaseMutationAtom).mutateAsync({ - body: { - appInstanceId, - sourceAppId: effectiveSelectedApp.id, - displayName: submittedReleaseName, - description: submittedReleaseDescription || undefined, - createAppInstance: false, - }, - }) - - return appInstanceId - } - finally { - set(isCreatingReleaseOnlyAtom, false) - } - } - - if (!get(canDeployAtom)) - return undefined - - set(isCreatingDeploymentAtom, true) - - try { - const selectedEnvironmentIdentifier = selectedEnvironmentId.trim() - const freshSelectedEnvironment = selectedEnvironment || ( - selectedEnvironmentIdentifier - ? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment => - environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier), - ) - : undefined - ) - const targetEnvironmentId = freshSelectedEnvironment?.id - if (!targetEnvironmentId) - throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed') - - if (!get(requiredBindingsReadyAtom)) - throw new Error('Missing required deployment binding.') - if (!get(requiredEnvVarsReadyAtom)) - throw new Error('Missing required deployment environment variable.') - - const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key])) - const commonDeploymentRequest = { - newAppInstance: { - displayName: submittedInstanceName, - description: get(instanceDescriptionAtom).trim() || undefined, - }, - environmentId: targetEnvironmentId, - releaseName: submittedReleaseName, - releaseDescription: submittedReleaseDescription || undefined, - credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections), - envVars, - idempotencyKey: createDeploymentIdempotencyKey(), - expectedDslDigest: deploymentOptions?.dslDigest, - } satisfies Omit - const deploymentRequest = method === 'importDsl' - ? { - ...commonDeploymentRequest, - dsl: encodeDslContent(dslContent), - } - : effectiveSelectedApp?.id - ? { - ...commonDeploymentRequest, - sourceAppId: effectiveSelectedApp.id, - } - : undefined - if (!deploymentRequest) - return undefined - - const response = await get(createInitialDeploymentMutationAtom).mutateAsync({ - body: deploymentRequest, - }) - - return response.appInstance.id - } - finally { - set(isCreatingDeploymentAtom, false) - } - } - catch (error) { - const unsupportedError = await unsupportedDslNodeError(error) - if (unsupportedError?.nodes.length) { - set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes) - - return undefined - } - - throw error - } -}) - -// Scoped local state -export const createDeploymentGuideScopedAtoms = [ - stepAtom, - methodAtom, - sourceSearchTextAtom, - selectedAppAtom, - dslFileAtom, - dslFileReadVersionAtom, - instanceNameAtom, - instanceDescriptionAtom, - releaseNameAtom, - releaseDescriptionAtom, - autoFilledInstanceNameAtom, - autoFilledReleaseNameAtom, - selectedEnvironmentIdAtom, - manualBindingSelectionsAtom, - envVarValuesAtom, - submissionUnsupportedDslNodesAtom, - isCreatingDeploymentAtom, - isCreatingReleaseOnlyAtom, -] diff --git a/web/features/deployments/create-guide/state/primitives.ts b/web/features/deployments/create-guide/state/primitives.ts new file mode 100644 index 00000000000..3332e68acee --- /dev/null +++ b/web/features/deployments/create-guide/state/primitives.ts @@ -0,0 +1,37 @@ +'use client' + +import type { GuideMethod, GuideStep, WorkflowSourceApp } from './types' +import type { EnvVarValues } from '@/features/deployments/shared/components/env-var-bindings' +import type { RuntimeCredentialBindingSelections } from '@/features/deployments/shared/components/runtime-credential-bindings-utils' +import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error' +import { atom } from 'jotai' +import { deploymentGuideMethod } from './utils' + +export const stepAtom = atom('source') +export const methodAtom = atom('bindApp') +export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom))) + +export const sourceSearchTextAtom = atom('') +export const selectedAppAtom = atom(undefined) + +export const dslFileAtom = atom(undefined) +export const dslFileReadVersionAtom = atom(0) + +export const instanceNameAtom = atom('') +export const instanceDescriptionAtom = atom('') +export const releaseNameAtom = atom('') +export const releaseDescriptionAtom = atom('') +export const autoFilledInstanceNameAtom = atom('') +export const autoFilledReleaseNameAtom = atom('') + +export const selectedEnvironmentIdAtom = atom('') +export const manualBindingSelectionsAtom = atom({}) +export const envVarValuesAtom = atom({}) + +export const submissionUnsupportedDslNodesAtom = atom([]) +export const isCreatingDeploymentAtom = atom(false) +export const isCreatingReleaseOnlyAtom = atom(false) + +export const isSubmittingDeploymentGuideAtom = atom(get => ( + get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom) +)) diff --git a/web/features/deployments/create-guide/state/provider.tsx b/web/features/deployments/create-guide/state/provider.tsx index 5ebf3d8bd3a..85885eb3669 100644 --- a/web/features/deployments/create-guide/state/provider.tsx +++ b/web/features/deployments/create-guide/state/provider.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { ScopeProvider } from 'jotai-scope' -import { createDeploymentGuideScopedAtoms } from './index' +import { createDeploymentGuideScopedAtoms } from './scoped' export function CreateDeploymentGuideProvider({ children }: { children: ReactNode diff --git a/web/features/deployments/create-guide/state/queries.ts b/web/features/deployments/create-guide/state/queries.ts new file mode 100644 index 00000000000..22541b25b74 --- /dev/null +++ b/web/features/deployments/create-guide/state/queries.ts @@ -0,0 +1,169 @@ +'use client' + +import type { Getter } from 'jotai/vanilla' +import { keepPreviousData, skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { encodeDslContent } from '@/features/deployments/shared/domain/dsl' +import { consoleQuery } from '@/service/client' +import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives' +import { dslContentAtom, effectiveSelectedAppAtom, sourceReady } from './source' +import { DEPLOYMENT_PAGE_SIZE, getNextPageParamFromPagination } from './utils' + +export const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() => + consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({ + input: pageParam => ({ + query: { + pageNumber: Number(pageParam), + resultsPerPage: DEPLOYMENT_PAGE_SIZE, + }, + }), + getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination), + initialPageParam: 1, + placeholderData: keepPreviousData, + }), +) + +export const instanceNameConflictQueryAtom = atomWithQuery((get) => { + const submittedInstanceName = get(instanceNameAtom).trim() + + return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({ + input: { + query: { + pageNumber: 1, + resultsPerPage: 1, + displayName: submittedInstanceName, + }, + }, + enabled: Boolean(submittedInstanceName), + }) +}) + +export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => { + return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({ + input: { + query: { + // The guide offers every deployable environment at once; environment + // count is capped well below the 100-per-page maximum. + pageNumber: 1, + resultsPerPage: 100, + }, + }, + enabled: sourceReady(get), + }) +}) + +const precheckReleaseQueryAtom = atomWithQuery((get) => { + const method = get(effectiveMethodAtom) + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + const dslContent = get(dslContentAtom) + const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined + const enabled = sourceReady(get) + + // PrecheckRelease takes exactly one source arm (dsl | sourceAppId). + const precheckReleaseQueryOptions = method === 'importDsl' + ? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ + input: encodedDslContent + ? { + body: { + dsl: encodedDslContent, + }, + } + : skipToken, + enabled, + retry: false, + }) + : consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ + input: effectiveSelectedApp?.id + ? { + body: { + sourceAppId: effectiveSelectedApp.id, + }, + } + : skipToken, + enabled: enabled && Boolean(effectiveSelectedApp?.id), + retry: false, + }) + + return precheckReleaseQueryOptions +}) + +function precheckReleaseReady(get: Getter) { + const precheckReleaseQuery = get(precheckReleaseQueryAtom) + + return sourceReady(get) + && precheckReleaseQuery.isSuccess + && Boolean(precheckReleaseQuery.data?.canCreate) + && (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0 + && get(submissionUnsupportedDslNodesAtom).length === 0 +} + +export const deploymentOptionsQueryAtom = atomWithQuery((get) => { + const method = get(effectiveMethodAtom) + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + const dslContent = get(dslContentAtom) + const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined + const enabled = precheckReleaseReady(get) + + // ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId). + const deploymentOptionsQueryOptions = method === 'importDsl' + ? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ + input: encodedDslContent + ? { + body: { + dsl: encodedDslContent, + }, + } + : skipToken, + enabled, + retry: false, + }) + : consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({ + input: effectiveSelectedApp?.id + ? { + body: { + sourceAppId: effectiveSelectedApp.id, + }, + } + : skipToken, + enabled: enabled && Boolean(effectiveSelectedApp?.id), + retry: false, + }) + + return deploymentOptionsQueryOptions +}) + +export const unsupportedDslNodesAtom = atom((get) => { + const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom) + if (submissionUnsupportedDslNodes.length > 0) + return submissionUnsupportedDslNodes + + if (!sourceReady(get)) + return [] + + return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? [] +}) + +const precheckReleaseReadyAtom = atom((get) => { + return precheckReleaseReady(get) +}) + +export const deploymentOptionsReadyAtom = atom((get) => { + const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) + + return sourceReady(get) + && get(precheckReleaseReadyAtom) + && deploymentOptionsQuery.isSuccess +}) + +export const deploymentOptionsContentCheckedAtom = atom((get) => { + const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) + const precheckReleaseQuery = get(precheckReleaseQueryAtom) + const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data) + const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data) + + if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions) + return false + + return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess +}) diff --git a/web/features/deployments/create-guide/state/release.ts b/web/features/deployments/create-guide/state/release.ts new file mode 100644 index 00000000000..0fb26fd3167 --- /dev/null +++ b/web/features/deployments/create-guide/state/release.ts @@ -0,0 +1,84 @@ +'use client' + +import { atom } from 'jotai' +import { + autoFilledInstanceNameAtom, + autoFilledReleaseNameAtom, + envVarValuesAtom, + instanceDescriptionAtom, + instanceNameAtom, + manualBindingSelectionsAtom, + releaseDescriptionAtom, + releaseNameAtom, + selectedEnvironmentIdAtom, + stepAtom, +} from './primitives' +import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom, instanceNameConflictQueryAtom } from './queries' +import { sourceReady } from './source' + +export const hasInstanceNameConflictAtom = atom((get) => { + const submittedInstanceName = get(instanceNameAtom).trim() + const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) + const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) + const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page => + page.appInstances.flatMap((appInstance) => { + const name = appInstance.displayName.trim() + + return name ? [name] : [] + }), + ) ?? [] + + return Boolean( + submittedInstanceName + && ( + existingInstanceNames.includes(submittedInstanceName) + || (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false) + ), + ) +}) + +export const submittedReleaseReadyAtom = atom((get) => { + return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim()) +}) + +export const releaseCanGoNextAtom = atom((get) => { + const submittedInstanceName = get(instanceNameAtom).trim() + const instanceNameConflictQuery = get(instanceNameConflictQueryAtom) + + return Boolean(get(submittedReleaseReadyAtom)) + && !get(hasInstanceNameConflictAtom) + && !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading) + && get(deploymentOptionsContentCheckedAtom) +}) + +export const setInstanceNameAtom = atom(null, (_get, set, value: string) => { + set(instanceNameAtom, value) + set(autoFilledInstanceNameAtom, '') + set(stepAtom, 'release') +}) + +export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => { + set(instanceDescriptionAtom, value) + set(stepAtom, 'release') +}) + +export const setReleaseNameAtom = atom(null, (_get, set, value: string) => { + set(releaseNameAtom, value) + set(autoFilledReleaseNameAtom, '') + set(stepAtom, 'release') +}) + +export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => { + set(releaseDescriptionAtom, value) + set(stepAtom, 'release') +}) + +export const continueFromReleaseAtom = atom(null, (get, set) => { + if (!get(releaseCanGoNextAtom)) + return + + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(stepAtom, 'target') +}) diff --git a/web/features/deployments/create-guide/state/scoped.ts b/web/features/deployments/create-guide/state/scoped.ts new file mode 100644 index 00000000000..0abe15b295f --- /dev/null +++ b/web/features/deployments/create-guide/state/scoped.ts @@ -0,0 +1,41 @@ +import { + autoFilledInstanceNameAtom, + autoFilledReleaseNameAtom, + dslFileAtom, + dslFileReadVersionAtom, + envVarValuesAtom, + instanceDescriptionAtom, + instanceNameAtom, + isCreatingDeploymentAtom, + isCreatingReleaseOnlyAtom, + manualBindingSelectionsAtom, + methodAtom, + releaseDescriptionAtom, + releaseNameAtom, + selectedAppAtom, + selectedEnvironmentIdAtom, + sourceSearchTextAtom, + stepAtom, + submissionUnsupportedDslNodesAtom, +} from './primitives' + +export const createDeploymentGuideScopedAtoms = [ + stepAtom, + methodAtom, + sourceSearchTextAtom, + selectedAppAtom, + dslFileAtom, + dslFileReadVersionAtom, + instanceNameAtom, + instanceDescriptionAtom, + releaseNameAtom, + releaseDescriptionAtom, + autoFilledInstanceNameAtom, + autoFilledReleaseNameAtom, + selectedEnvironmentIdAtom, + manualBindingSelectionsAtom, + envVarValuesAtom, + submissionUnsupportedDslNodesAtom, + isCreatingDeploymentAtom, + isCreatingReleaseOnlyAtom, +] diff --git a/web/features/deployments/create-guide/state/source.ts b/web/features/deployments/create-guide/state/source.ts new file mode 100644 index 00000000000..b711a49a7e2 --- /dev/null +++ b/web/features/deployments/create-guide/state/source.ts @@ -0,0 +1,110 @@ +'use client' + +import type { Getter } from 'jotai/vanilla' +import type { WorkflowSourceApp } from './types' +import { keepPreviousData, queryOptions } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl' +import { consoleQuery } from '@/service/client' +import { AppModeEnum } from '@/types/app' +import { dslFileAtom, dslFileReadVersionAtom, effectiveMethodAtom, selectedAppAtom, sourceSearchTextAtom } from './primitives' +import { SOURCE_APPS_PAGE_SIZE } from './utils' + +const dslFileContentQueryAtom = atomWithQuery((get) => { + const file = get(dslFileAtom) + const fileReadVersion = get(dslFileReadVersionAtom) + + return queryOptions({ + queryKey: [ + 'createGuideDslFileContent', + fileReadVersion, + file, + file?.name ?? '', + file?.size ?? 0, + file?.lastModified ?? 0, + ], + queryFn: async () => file ? await file.text() : '', + enabled: Boolean(file), + retry: false, + }) +}) + +export const dslContentAtom = atom((get) => { + return get(dslFileContentQueryAtom).data ?? '' +}) + +export const isReadingDslAtom = atom((get) => { + const file = get(dslFileAtom) + const dslFileContentQuery = get(dslFileContentQueryAtom) + + return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching)) +}) + +export const dslReadErrorAtom = atom((get) => { + return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError) +}) + +export const dslDefaultAppNameAtom = atom((get) => { + const dslContent = get(dslContentAtom) + + return dslContent ? dslAppName(dslContent) : '' +}) + +export const dslUnsupportedModeAtom = atom((get) => { + const dslContent = get(dslContentAtom) + + return get(effectiveMethodAtom) === 'importDsl' + && Boolean(dslContent.trim()) + && !get(isReadingDslAtom) + && !get(dslReadErrorAtom) + && !isWorkflowDsl(dslContent) +}) + +export const importDslReadyAtom = atom((get) => { + return Boolean(get(dslContentAtom).trim()) + && !get(isReadingDslAtom) + && !get(dslReadErrorAtom) + && !get(dslUnsupportedModeAtom) +}) + +export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { + const sourceSearchText = get(sourceSearchTextAtom) + + return consoleQuery.apps.list.infiniteOptions({ + input: pageParam => ({ + query: { + page: Number(pageParam), + limit: SOURCE_APPS_PAGE_SIZE, + name: sourceSearchText, + mode: AppModeEnum.WORKFLOW, + }, + }), + getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined, + initialPageParam: 1, + placeholderData: keepPreviousData, + enabled: get(effectiveMethodAtom) === 'bindApp', + }) +}) + +export const effectiveSelectedAppAtom = atom((get) => { + const selectedApp = get(selectedAppAtom) + if (selectedApp) + return selectedApp + + const sourceAppsQuery = get(sourceAppsQueryAtom) + if (sourceAppsQuery.isPlaceholderData) + return undefined + + const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[] + + return sourceApps[0] +}) + +export function sourceReady(get: Getter) { + const method = get(effectiveMethodAtom) + + return method === 'importDsl' + ? get(importDslReadyAtom) + : Boolean(get(effectiveSelectedAppAtom)?.id) +} diff --git a/web/features/deployments/create-guide/state/submission.ts b/web/features/deployments/create-guide/state/submission.ts new file mode 100644 index 00000000000..675598e6307 --- /dev/null +++ b/web/features/deployments/create-guide/state/submission.ts @@ -0,0 +1,221 @@ +'use client' + +import type { DeployRequest } from '@dify/contracts/enterprise/types.gen' +import { atom } from 'jotai' +import { atomWithMutation } from 'jotai-tanstack-query' +import { selectedDeploymentRuntimeCredentials } from '@/features/deployments/shared/components/runtime-credential-bindings-utils' +import { encodeDslContent, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl' +import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error' +import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency' +import { consoleQuery } from '@/service/client' +import { environmentMatchesIdentifier } from './environment' +import { + effectiveMethodAtom, + envVarValuesAtom, + instanceDescriptionAtom, + instanceNameAtom, + isCreatingDeploymentAtom, + isCreatingReleaseOnlyAtom, + isSubmittingDeploymentGuideAtom, + releaseDescriptionAtom, + releaseNameAtom, + selectedEnvironmentIdAtom, + submissionUnsupportedDslNodesAtom, +} from './primitives' +import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom } from './queries' +import { submittedReleaseReadyAtom } from './release' +import { dslContentAtom, effectiveSelectedAppAtom } from './source' +import { + canDeployAtom, + canSkipDeploymentAtom, + deployableEnvironmentsAtom, + deploymentTargetBindingSelectionsAtom, + deploymentTargetBindingSlotsAtom, + deploymentTargetEnvVarSlotsAtom, + requiredBindingsReadyAtom, + requiredEnvVarsReadyAtom, +} from './target' +import { envVarInput } from './utils' + +const createAppInstanceMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(), +) + +const createReleaseMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.releaseService.createRelease.mutationOptions(), +) + +const createInitialDeploymentMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.deploymentService.deploy.mutationOptions(), +) + +export class CreateDeploymentGuideSubmissionBlockedError extends Error { + reason: 'unsupportedDslMode' | 'deployFailed' + + constructor(reason: 'unsupportedDslMode' | 'deployFailed') { + super(reason) + this.reason = reason + this.name = 'CreateDeploymentGuideSubmissionBlockedError' + } +} + +export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { + deployToEnvironment, +}: { + deployToEnvironment: boolean +}) => { + const method = get(effectiveMethodAtom) + const dslContent = get(dslContentAtom) + const submittedInstanceName = get(instanceNameAtom).trim() + const submittedReleaseName = get(releaseNameAtom).trim() + const submittedReleaseDescription = get(releaseDescriptionAtom).trim() + + if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom)) + return undefined + + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) + const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options + const envVarSlots = get(deploymentTargetEnvVarSlotsAtom) + const envVarValues = get(envVarValuesAtom) + const bindingSlots = get(deploymentTargetBindingSlotsAtom) + const bindingSelections = get(deploymentTargetBindingSelectionsAtom) + const selectedEnvironmentId = get(selectedEnvironmentIdAtom) + const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id + const selectedEnvironment = effectiveSelectedEnvironmentId + ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) + : undefined + + if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim()) + return undefined + if (method === 'bindApp' && !effectiveSelectedApp?.id) + return undefined + if (method === 'importDsl' && !dslContent.trim()) + return undefined + if (method === 'importDsl' && !isWorkflowDsl(dslContent)) + throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode') + + set(submissionUnsupportedDslNodesAtom, []) + + try { + if (!deployToEnvironment) { + if (!get(canSkipDeploymentAtom)) + return undefined + + set(isCreatingReleaseOnlyAtom, true) + + try { + const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({ + body: { + displayName: submittedInstanceName, + description: get(instanceDescriptionAtom).trim() || undefined, + }, + }) + const appInstanceId = createdAppInstance.appInstance.id + + if (method === 'importDsl') { + await get(createReleaseMutationAtom).mutateAsync({ + body: { + appInstanceId, + dsl: encodeDslContent(dslContent), + displayName: submittedReleaseName, + description: submittedReleaseDescription || undefined, + createAppInstance: false, + }, + }) + + return appInstanceId + } + + if (!effectiveSelectedApp?.id) + return undefined + + await get(createReleaseMutationAtom).mutateAsync({ + body: { + appInstanceId, + sourceAppId: effectiveSelectedApp.id, + displayName: submittedReleaseName, + description: submittedReleaseDescription || undefined, + createAppInstance: false, + }, + }) + + return appInstanceId + } + finally { + set(isCreatingReleaseOnlyAtom, false) + } + } + + if (!get(canDeployAtom)) + return undefined + + set(isCreatingDeploymentAtom, true) + + try { + const selectedEnvironmentIdentifier = selectedEnvironmentId.trim() + const freshSelectedEnvironment = selectedEnvironment || ( + selectedEnvironmentIdentifier + ? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment => + environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier), + ) + : undefined + ) + const targetEnvironmentId = freshSelectedEnvironment?.id + if (!targetEnvironmentId) + throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed') + + if (!get(requiredBindingsReadyAtom)) + throw new Error('Missing required deployment binding.') + if (!get(requiredEnvVarsReadyAtom)) + throw new Error('Missing required deployment environment variable.') + + const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key])) + const commonDeploymentRequest = { + newAppInstance: { + displayName: submittedInstanceName, + description: get(instanceDescriptionAtom).trim() || undefined, + }, + environmentId: targetEnvironmentId, + releaseName: submittedReleaseName, + releaseDescription: submittedReleaseDescription || undefined, + credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections), + envVars, + idempotencyKey: createDeploymentIdempotencyKey(), + expectedDslDigest: deploymentOptions?.dslDigest, + } satisfies Omit + const deploymentRequest = method === 'importDsl' + ? { + ...commonDeploymentRequest, + dsl: encodeDslContent(dslContent), + } + : effectiveSelectedApp?.id + ? { + ...commonDeploymentRequest, + sourceAppId: effectiveSelectedApp.id, + } + : undefined + if (!deploymentRequest) + return undefined + + const response = await get(createInitialDeploymentMutationAtom).mutateAsync({ + body: deploymentRequest, + }) + + return response.appInstance.id + } + finally { + set(isCreatingDeploymentAtom, false) + } + } + catch (error) { + const unsupportedError = await unsupportedDslNodeError(error) + if (unsupportedError?.nodes.length) { + set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes) + + return undefined + } + + throw error + } +}) diff --git a/web/features/deployments/create-guide/state/target.ts b/web/features/deployments/create-guide/state/target.ts new file mode 100644 index 00000000000..36b3fee69d9 --- /dev/null +++ b/web/features/deployments/create-guide/state/target.ts @@ -0,0 +1,153 @@ +'use client' + +import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings' +import { atom } from 'jotai' +import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/shared/components/env-var-bindings-utils' +import { + hasMissingRequiredRuntimeCredentialBinding, + runtimeCredentialSlotKey, + selectedRuntimeCredentialSelections, +} from '@/features/deployments/shared/components/runtime-credential-bindings-utils' +import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl' +import { environmentMatchesIdentifier } from './environment' +import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives' +import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom, deploymentOptionsReadyAtom } from './queries' +import { submittedReleaseReadyAtom } from './release' +import { dslContentAtom, sourceReady } from './source' +import { envVarSelectionReady } from './utils' + +export const deployableEnvironmentsAtom = atom((get) => { + const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) + + return sourceReady(get) + ? deployableEnvironmentsQuery.data?.environments ?? [] + : [] +}) + +const deployableEnvironmentsReadyAtom = atom((get) => { + const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom) + + return sourceReady(get) && deployableEnvironmentsQuery.isSuccess +}) + +export const effectiveSelectedEnvironmentIdAtom = atom((get) => { + return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id +}) + +export const deploymentTargetBindingSlotsAtom = atom((get) => { + const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) + + return sourceReady(get) + ? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? [] + : [] +}) + +export const deploymentTargetBindingSelectionsAtom = atom((get) => { + return selectedRuntimeCredentialSelections( + get(deploymentTargetBindingSlotsAtom), + get(manualBindingSelectionsAtom), + ) +}) + +export const requiredBindingsReadyAtom = atom((get) => { + const bindingSelections = get(deploymentTargetBindingSelectionsAtom) + + return get(deploymentTargetBindingSlotsAtom).every(slot => + !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]), + ) +}) + +export const deploymentTargetEnvVarSlotsAtom = atom((get) => { + const method = get(effectiveMethodAtom) + const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) + const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined + const dslContent = get(dslContentAtom) + + // Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults. + const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => { + const bindingSlot = envVarBindingSlotFromContract(slot) + return bindingSlot ? [bindingSlot] : [] + }) ?? [] + const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent + ? dslEnvVarSlots(dslContent).flatMap((slot) => { + const key = slot.key.trim() + if (!key) + return [] + + return [{ + key, + ...(slot.description ? { description: slot.description } : {}), + ...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}), + ...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}), + }] + }) + : [] + + if (dslEnvVarMetadataSlots.length === 0) + return deploymentOptionEnvVarSlots + + const metadataByKey = new Map( + dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const), + ) + + return deploymentOptionEnvVarSlots.map((slot) => { + const metadata = metadataByKey.get(slot.key) + if (!metadata) + return slot + + const nextSlot = { ...slot } + + if (!nextSlot.description && metadata.description) + nextSlot.description = metadata.description + if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) { + nextSlot.defaultValue = metadata.defaultValue + nextSlot.hasDefaultValue = true + } + if (nextSlot.valueType === 'string' && metadata.valueType) + nextSlot.valueType = metadata.valueType + + return nextSlot + }) +}) + +export const requiredEnvVarsReadyAtom = atom((get) => { + const envVarValues = get(envVarValuesAtom) + + return get(deploymentTargetEnvVarSlotsAtom).every(slot => + envVarSelectionReady(slot, envVarValues[slot.key]), + ) +}) + +export const canDeployAtom = atom((get) => { + const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom) + const selectedEnvironment = effectiveSelectedEnvironmentId + ? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) + : undefined + + return Boolean( + selectedEnvironment?.id + && get(deployableEnvironmentsReadyAtom) + && get(deploymentOptionsReadyAtom) + && get(requiredBindingsReadyAtom) + && get(requiredEnvVarsReadyAtom) + && get(submittedReleaseReadyAtom), + ) +}) + +export const canSkipDeploymentAtom = atom((get) => { + return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom) +}) + +export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => { + set(manualBindingSelectionsAtom, { + ...get(manualBindingSelectionsAtom), + [slot]: value, + }) +}) + +export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => { + set(envVarValuesAtom, { + ...get(envVarValuesAtom), + [key]: value, + }) +}) diff --git a/web/features/deployments/create-guide/state/types.ts b/web/features/deployments/create-guide/state/types.ts new file mode 100644 index 00000000000..8f75751119b --- /dev/null +++ b/web/features/deployments/create-guide/state/types.ts @@ -0,0 +1,5 @@ +import type { App, AppModeEnum } from '@/types/app' + +export type GuideMethod = 'bindApp' | 'importDsl' +export type GuideStep = 'source' | 'release' | 'target' +export type WorkflowSourceApp = App & { mode: Extract } diff --git a/web/features/deployments/create-guide/state/utils.ts b/web/features/deployments/create-guide/state/utils.ts new file mode 100644 index 00000000000..fa07e418dfe --- /dev/null +++ b/web/features/deployments/create-guide/state/utils.ts @@ -0,0 +1,101 @@ +import type { EnvVarInput, Pagination } from '@dify/contracts/enterprise/types.gen' +import type { GuideMethod } from './types' +import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings' +import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' +import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' + +export const DEPLOYMENT_PAGE_SIZE = 100 +export const SOURCE_APPS_PAGE_SIZE = 100 + +export function getNextPageParamFromPagination(pagination?: Pagination) { + const currentPage = pagination?.currentPage ?? 1 + const totalPages = pagination?.totalPages ?? 1 + + return currentPage < totalPages ? currentPage + 1 : undefined +} + +export function deploymentGuideMethod(method: GuideMethod): GuideMethod { + return method === 'importDsl' && !isDeploymentDslImportEnabled + ? 'bindApp' + : method +} + +const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' +const RANDOM_SUFFIX_LENGTH = 4 +const RANDOM_SUFFIX_FALLBACK_LENGTH = 6 +const RANDOM_SUFFIX_MAX_ATTEMPTS = 16 + +function randomLetterCombination(length: number) { + const randomValues = new Uint8Array(length) + + if (globalThis.crypto) { + globalThis.crypto.getRandomValues(randomValues) + } + else { + randomValues.forEach((_, index) => { + randomValues[index] = Math.floor(Math.random() * 256) + }) + } + + return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('') +} + +export function availableInstanceName(sourceName: string, existingNameSet: Set) { + if (!existingNameSet.has(sourceName)) + return sourceName + + for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) { + const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}` + if (!existingNameSet.has(candidate)) + return candidate + } + + return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}` +} + +function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { + return selection?.valueSource + ?? (slot.hasDefaultValue + ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT + : slot.hasLastValue + ? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT + : ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL) +} + +export function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) { + const valueSource = envVarValueSource(slot, selection) + + if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) + return Boolean(slot.hasLastValue) + if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) + return Boolean(slot.hasDefaultValue) + if (!selection?.value) + return false + + return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value)) +} + +export function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] { + const valueSource = envVarValueSource(slot, selection) + + if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) { + return slot.hasLastValue + ? [{ key: slot.key, valueSource }] + : [] + } + + if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) { + return slot.hasDefaultValue + ? [{ key: slot.key, valueSource }] + : [] + } + + if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value)))) + return [] + + return [{ + key: slot.key, + value: selection.value, + valueSource, + }] +} diff --git a/web/features/deployments/create-guide/state/workflow.ts b/web/features/deployments/create-guide/state/workflow.ts new file mode 100644 index 00000000000..ba328039f80 --- /dev/null +++ b/web/features/deployments/create-guide/state/workflow.ts @@ -0,0 +1,122 @@ +'use client' + +import type { WorkflowSourceApp } from './types' +import { atom } from 'jotai' +import { + autoFilledInstanceNameAtom, + autoFilledReleaseNameAtom, + dslFileAtom, + dslFileReadVersionAtom, + effectiveMethodAtom, + envVarValuesAtom, + instanceNameAtom, + manualBindingSelectionsAtom, + methodAtom, + releaseNameAtom, + selectedAppAtom, + selectedEnvironmentIdAtom, + sourceSearchTextAtom, + stepAtom, + submissionUnsupportedDslNodesAtom, +} from './primitives' +import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom } from './queries' +import { dslDefaultAppNameAtom, effectiveSelectedAppAtom, importDslReadyAtom } from './source' +import { availableInstanceName, deploymentGuideMethod } from './utils' + +export const sourceCanGoNextAtom = atom((get) => { + const method = get(effectiveMethodAtom) + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + const importDslReady = method === 'importDsl' && get(importDslReadyAtom) + const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) + + return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom) +}) + +export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => { + if (get(sourceSearchTextAtom) === value) + return + + set(sourceSearchTextAtom, value) + set(selectedAppAtom, undefined) + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(submissionUnsupportedDslNodesAtom, []) +}) + +export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => { + set(selectedAppAtom, app) + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(submissionUnsupportedDslNodesAtom, []) +}) + +export const continueFromSourceAtom = atom(null, (get, set, { + defaultDslAppName, + defaultReleaseName, +}: { + defaultDslAppName: string + defaultReleaseName: string +}) => { + if (!get(sourceCanGoNextAtom)) + return + + const method = get(effectiveMethodAtom) + const effectiveSelectedApp = get(effectiveSelectedAppAtom) + if (method === 'bindApp' && effectiveSelectedApp) + set(selectSourceAppAtom, effectiveSelectedApp) + + const sourceName = method === 'importDsl' + ? get(dslDefaultAppNameAtom) || defaultDslAppName + : effectiveSelectedApp?.name + const nextInstanceName = sourceName?.trim() + + if (nextInstanceName) { + const currentInstanceName = get(instanceNameAtom).trim() + const autoFilledInstanceName = get(autoFilledInstanceNameAtom) + const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom) + const existingNameSet = new Set( + existingInstanceNamesQuery.data?.pages.flatMap(page => + page.appInstances.flatMap((appInstance) => { + const name = appInstance.displayName.trim() + + return name ? [name] : [] + }), + ) ?? [], + ) + + if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) { + const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet) + set(instanceNameAtom, nextAvailableInstanceName) + set(autoFilledInstanceNameAtom, nextAvailableInstanceName) + } + } + + const currentReleaseName = get(releaseNameAtom).trim() + const autoFilledReleaseName = get(autoFilledReleaseNameAtom) + if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) { + set(releaseNameAtom, defaultReleaseName) + set(autoFilledReleaseNameAtom, defaultReleaseName) + } + set(stepAtom, 'release') +}) + +export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => { + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(submissionUnsupportedDslNodesAtom, []) + + set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1) + set(dslFileAtom, dslFile) +}) + +export const selectMethodAtom = atom(null, (_get, set, method: Parameters[0]) => { + set(methodAtom, deploymentGuideMethod(method)) + set(selectedEnvironmentIdAtom, '') + set(manualBindingSelectionsAtom, {}) + set(envVarValuesAtom, {}) + set(submissionUnsupportedDslNodesAtom, []) + set(stepAtom, 'source') +}) diff --git a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx index d0a1eaae638..46fea37411f 100644 --- a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -16,9 +16,7 @@ const mocks = vi.hoisted(() => { return { sourceAppsQuery, useInfiniteScroll: vi.fn(() => ({ - rootEl: null, rootRef: vi.fn(), - sentinelEl: null, sentinelRef: vi.fn(), })), } @@ -28,29 +26,51 @@ vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({ useInfiniteScroll: mocks.useInfiniteScroll, })) -vi.mock('@/features/deployments/create-guide/state', async () => { +vi.mock('@/features/deployments/create-guide/state/primitives', async () => { const { atom } = await import('jotai') const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp') + + return { + dslFileAtom: atom(undefined), + effectiveMethodAtom: atom(get => get(methodAtom)), + methodAtom, + sourceSearchTextAtom: atom(''), + } +}) + +vi.mock('@/features/deployments/create-guide/state/source', async () => { + const { atom } = await import('jotai') + + return { + dslReadErrorAtom: atom(false), + dslUnsupportedModeAtom: atom(false), + effectiveSelectedAppAtom: atom(undefined), + isReadingDslAtom: atom(false), + sourceAppsQueryAtom: atom(mocks.sourceAppsQuery), + } +}) + +vi.mock('@/features/deployments/create-guide/state/workflow', async () => { + const { atom } = await import('jotai') + const { methodAtom } = await import('@/features/deployments/create-guide/state/primitives') const emptyActionAtom = atom(null, () => undefined) return { continueFromSourceAtom: emptyActionAtom, - dslFileAtom: atom(undefined), - dslReadErrorAtom: atom(false), - dslUnsupportedModeAtom: atom(false), - effectiveMethodAtom: atom(get => get(methodAtom)), - effectiveSelectedAppAtom: atom(undefined), - isReadingDslAtom: atom(false), - methodAtom, selectDslFileAtom: emptyActionAtom, selectMethodAtom: atom(null, (_get, set, value: 'bindApp' | 'importDsl') => { set(methodAtom, value) }), selectSourceAppAtom: emptyActionAtom, setSourceSearchTextAtom: emptyActionAtom, - sourceAppsQueryAtom: atom(mocks.sourceAppsQuery), sourceCanGoNextAtom: atom(false), - sourceSearchTextAtom: atom(''), + } +}) + +vi.mock('@/features/deployments/create-guide/state/queries', async () => { + const { atom } = await import('jotai') + + return { unsupportedDslNodesAtom: atom([]), } }) diff --git a/web/features/deployments/create-guide/ui/layout.tsx b/web/features/deployments/create-guide/ui/layout.tsx index e49e7654926..1e2fd448a81 100644 --- a/web/features/deployments/create-guide/ui/layout.tsx +++ b/web/features/deployments/create-guide/ui/layout.tsx @@ -1,11 +1,11 @@ 'use client' import type { ReactNode } from 'react' -import type { GuideStep } from '@/features/deployments/create-guide/state' +import type { GuideStep } from '@/features/deployments/create-guide/state/types' import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { useTranslation } from 'react-i18next' -import { TitleTooltip } from '@/features/deployments/components/title-tooltip' +import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip' const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target'] diff --git a/web/features/deployments/create-guide/ui/release-step.tsx b/web/features/deployments/create-guide/ui/release-step.tsx index 19494772935..48d991d9cf5 100644 --- a/web/features/deployments/create-guide/ui/release-step.tsx +++ b/web/features/deployments/create-guide/ui/release-step.tsx @@ -5,22 +5,24 @@ import { Input } from '@langgenius/dify-ui/input' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { - continueFromReleaseAtom, - dslDefaultAppNameAtom, effectiveMethodAtom, - hasInstanceNameConflictAtom, instanceDescriptionAtom, instanceNameAtom, - releaseCanGoNextAtom, releaseDescriptionAtom, releaseNameAtom, selectedAppAtom, + stepAtom, +} from '@/features/deployments/create-guide/state/primitives' +import { + continueFromReleaseAtom, + hasInstanceNameConflictAtom, + releaseCanGoNextAtom, setInstanceDescriptionAtom, setInstanceNameAtom, setReleaseDescriptionAtom, setReleaseNameAtom, - stepAtom, -} from '@/features/deployments/create-guide/state' +} from '@/features/deployments/create-guide/state/release' +import { dslDefaultAppNameAtom } from '@/features/deployments/create-guide/state/source' import { StepShell } from './layout' const releaseTextareaClassName = 'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden 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' diff --git a/web/features/deployments/create-guide/ui/shell.tsx b/web/features/deployments/create-guide/ui/shell.tsx index 55496b65127..ce8cf285d5e 100644 --- a/web/features/deployments/create-guide/ui/shell.tsx +++ b/web/features/deployments/create-guide/ui/shell.tsx @@ -1,7 +1,7 @@ 'use client' import { useAtomValue } from 'jotai' -import { stepAtom } from '@/features/deployments/create-guide/state' +import { stepAtom } from '@/features/deployments/create-guide/state/primitives' import { GuideCard, GuideFrame } from './layout' import { ReleaseActionButtons, diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index be2dcff89c1..a5c5033083c 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -1,6 +1,6 @@ 'use client' -import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state' +import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' @@ -11,26 +11,30 @@ 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, + effectiveMethodAtom, + sourceSearchTextAtom, +} from '@/features/deployments/create-guide/state/primitives' +import { unsupportedDslNodesAtom } from '@/features/deployments/create-guide/state/queries' +import { dslReadErrorAtom, dslUnsupportedModeAtom, - effectiveMethodAtom, effectiveSelectedAppAtom, isReadingDslAtom, + sourceAppsQueryAtom, +} from '@/features/deployments/create-guide/state/source' +import { + continueFromSourceAtom, selectDslFileAtom, selectMethodAtom, selectSourceAppAtom, setSourceSearchTextAtom, - sourceAppsQueryAtom, sourceCanGoNextAtom, - sourceSearchTextAtom, - unsupportedDslNodesAtom, -} from '@/features/deployments/create-guide/state' +} from '@/features/deployments/create-guide/state/workflow' +import { DeploymentStateMessage } from '@/features/deployments/shared/components/empty-state' +import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip' +import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert' import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll' import { StepShell } from './layout' diff --git a/web/features/deployments/create-guide/ui/target-step.tsx b/web/features/deployments/create-guide/ui/target-step.tsx index 690d5131290..870e8527b8c 100644 --- a/web/features/deployments/create-guide/ui/target-step.tsx +++ b/web/features/deployments/create-guide/ui/target-step.tsx @@ -10,34 +10,40 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { - EnvVarBindingsPanel, -} from '@/features/deployments/components/env-var-bindings' + envVarValuesAtom, + isCreatingReleaseOnlyAtom, + isSubmittingDeploymentGuideAtom, + selectedEnvironmentIdAtom, + stepAtom, +} from '@/features/deployments/create-guide/state/primitives' import { - RuntimeCredentialBindingsPanel, -} from '@/features/deployments/components/runtime-credential-bindings' -import { TitleTooltip } from '@/features/deployments/components/title-tooltip' -import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert' + deployableEnvironmentsQueryAtom, + deploymentOptionsQueryAtom, + unsupportedDslNodesAtom, +} from '@/features/deployments/create-guide/state/queries' +import { + createDeploymentGuideSubmissionAtom, + CreateDeploymentGuideSubmissionBlockedError, +} from '@/features/deployments/create-guide/state/submission' import { canDeployAtom, canSkipDeploymentAtom, - createDeploymentGuideSubmissionAtom, - CreateDeploymentGuideSubmissionBlockedError, deployableEnvironmentsAtom, - deployableEnvironmentsQueryAtom, - deploymentOptionsQueryAtom, deploymentTargetBindingSelectionsAtom, deploymentTargetBindingSlotsAtom, deploymentTargetEnvVarSlotsAtom, effectiveSelectedEnvironmentIdAtom, - envVarValuesAtom, - isCreatingReleaseOnlyAtom, - isSubmittingDeploymentGuideAtom, selectBindingAtom, - selectedEnvironmentIdAtom, setEnvVarAtom, - stepAtom, - unsupportedDslNodesAtom, -} from '@/features/deployments/create-guide/state' +} from '@/features/deployments/create-guide/state/target' +import { + EnvVarBindingsPanel, +} from '@/features/deployments/shared/components/env-var-bindings' +import { + RuntimeCredentialBindingsPanel, +} from '@/features/deployments/shared/components/runtime-credential-bindings' +import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip' +import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert' import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error' import { useRouter } from '@/next/navigation' import { StepShell } from './layout' diff --git a/web/features/deployments/create-release/README.md b/web/features/deployments/create-release/README.md new file mode 100644 index 00000000000..504e2a9f38b --- /dev/null +++ b/web/features/deployments/create-release/README.md @@ -0,0 +1,18 @@ +# Deployment Create Release + +Release creation dialog for selecting a source app or DSL file and creating a new release for an existing deployment app instance. + +## Internal Modules + +| Module | Why this module uses it | +| -------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | + +## External Modules + +| Module | Why this module uses it | +| --------------------------------------------------- | -------------------------------------------------------------------------------- | +| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the DSL release source. | +| `app/components/base/app-icon` | Renders source app icons consistently in the source app picker. | +| `app/components/base/skeleton` | Reuses skeleton primitives for source app picker loading rows. | +| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. | diff --git a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts index 609b120b4f8..8b7ab05e8c0 100644 --- a/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts +++ b/web/features/deployments/create-release/state/__tests__/dsl-enabled.spec.ts @@ -1,5 +1,5 @@ import type { Getter } from 'jotai' -import { QueryClient } from '@tanstack/react-query' +import { QueryClient, skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -33,6 +33,10 @@ const mockQueryResults = vi.hoisted(() => ({ current: new Map(), })) +const mockQueryOptions = vi.hoisted(() => ({ + current: new Map(), +})) + const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({ current: { isPending: false, @@ -57,6 +61,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { ? undefined : mockQueryResults.current.get(queryName) + mockQueryOptions.current.set(queryName, options) + return { ...options, data: undefined, @@ -75,6 +81,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { ? undefined : mockQueryResults.current.get(queryName) + mockQueryOptions.current.set(queryName, options) + return { ...options, data: undefined, @@ -180,6 +188,7 @@ describe('create release state with DSL import enabled', () => { beforeEach(() => { vi.clearAllMocks() mockQueryResults.current.clear() + mockQueryOptions.current.clear() mockCreateReleaseMutation.current = { isPending: false, mutateAsync: vi.fn(), @@ -207,6 +216,22 @@ describe('create release state with DSL import enabled', () => { unsubscribe() }) + it('should skip DSL release precheck input until DSL content is ready', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + store.set(state.selectCreateReleaseSourceModeAtom, 'dsl') + store.get(state.isCheckingCreateReleaseContentAtom) + + expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({ + enabled: false, + input: skipToken, + }) + + unsubscribe() + }) + it('should submit a DSL release with encoded workflow content', async () => { const { state, store, unsubscribe } = await mountedStore() const response = { diff --git a/web/features/deployments/create-release/state/__tests__/index.spec.ts b/web/features/deployments/create-release/state/__tests__/index.spec.ts index 3ed7abae1dd..00a55a01a18 100644 --- a/web/features/deployments/create-release/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-release/state/__tests__/index.spec.ts @@ -1,6 +1,6 @@ import type { Getter } from 'jotai' import type { CreateReleaseFormValues } from '../index' -import { QueryClient } from '@tanstack/react-query' +import { QueryClient, skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,6 +31,10 @@ const mockQueryResults = vi.hoisted(() => ({ current: new Map(), })) +const mockQueryOptions = vi.hoisted(() => ({ + current: new Map(), +})) + const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({ current: { isPending: false, @@ -51,6 +55,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => { ? undefined : mockQueryResults.current.get(queryName) + mockQueryOptions.current.set(queryName, options) + return { ...options, data: undefined, @@ -226,6 +232,7 @@ describe('create release state', () => { beforeEach(() => { vi.clearAllMocks() mockQueryResults.current.clear() + mockQueryOptions.current.clear() mockCreateReleaseMutation.current = { isPending: false, mutateAsync: vi.fn(), @@ -334,6 +341,37 @@ describe('create release state', () => { unsubscribe() }) + it('should reset source app search text when opening or closing the dialog', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.createReleaseSourceAppSearchTextAtom, 'customer') + store.set(state.openCreateReleaseDialogAtom) + + expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('') + + store.set(state.createReleaseSourceAppSearchTextAtom, 'support') + store.set(state.closeCreateReleaseDialogAtom) + + expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('') + + unsubscribe() + }) + + it('should skip release content precheck input until source content is ready', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + store.get(state.isCheckingCreateReleaseContentAtom) + + expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({ + enabled: false, + input: skipToken, + }) + + unsubscribe() + }) + it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => { const { state, store, unsubscribe } = await mountedStore() const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' }) diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts index c88c54cccf9..0da75e39ebe 100644 --- a/web/features/deployments/create-release/state/index.ts +++ b/web/features/deployments/create-release/state/index.ts @@ -361,7 +361,7 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => { const canCheck = canCheckReleaseContent(get) return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ - input: appInstanceId + input: canCheck && appInstanceId ? releaseSourceMode === 'dsl' ? { body: { @@ -424,14 +424,20 @@ const resetCreateReleaseDslFileAtom = atom(null, (get, set) => { set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) }) +const resetCreateReleaseSourceAppSearchAtom = atom(null, (_get, set) => { + set(createReleaseSourceAppSearchTextAtom, '') +}) + export const openCreateReleaseDialogAtom = atom(null, (_get, set) => { set(resetCreateReleaseDslFileAtom) + set(resetCreateReleaseSourceAppSearchAtom) set(createReleaseDialogOpenAtom, true) }) export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => { set(createReleaseDialogOpenAtom, false) set(resetCreateReleaseDslFileAtom) + set(resetCreateReleaseSourceAppSearchAtom) }) export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => { diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx index d77bfde6f90..a33f2664042 100644 --- a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -25,9 +25,7 @@ const mocks = vi.hoisted(() => { return { sourceAppsQuery, useInfiniteScroll: vi.fn(() => ({ - rootEl: null, rootRef: vi.fn(), - sentinelEl: null, sentinelRef: vi.fn(), })), } diff --git a/web/features/deployments/create-release/ui/content-feedback.tsx b/web/features/deployments/create-release/ui/content-feedback.tsx index 8e7b2e323f4..0e3b0f91089 100644 --- a/web/features/deployments/create-release/ui/content-feedback.tsx +++ b/web/features/deployments/create-release/ui/content-feedback.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' -import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert' +import { UnsupportedDslNodesAlert } from '../../shared/components/unsupported-dsl-nodes-alert' import { createReleaseContentCheckFailedAtom, createReleaseMatchedReleaseAtom, diff --git a/web/features/deployments/create-release/ui/source-app-picker.tsx b/web/features/deployments/create-release/ui/source-app-picker.tsx index 2f3c06fb0bd..414d12a60f6 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll' -import { TitleTooltip } from '../../components/title-tooltip' +import { TitleTooltip } from '../../shared/components/title-tooltip' import { createReleaseSourceAppSearchTextAtom, createReleaseSourceAppsQueryAtom, diff --git a/web/features/deployments/deploy-drawer/README.md b/web/features/deployments/deploy-drawer/README.md new file mode 100644 index 00000000000..6e21080b393 --- /dev/null +++ b/web/features/deployments/deploy-drawer/README.md @@ -0,0 +1,15 @@ +# Deployment Deploy Drawer + +Drawer workflow for choosing a release, selecting an environment, binding runtime credentials, and starting a deployment. + +## Internal Modules + +| Module | Why this module uses it | +| -------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | + +## External Modules + +| Module | Why this module uses it | +| ------------------------------ | ------------------------------------------------------------ | +| `app/components/base/skeleton` | Reuses skeleton primitives for drawer form loading sections. | diff --git a/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts b/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts index 394dd8d81b3..b2df644f1e7 100644 --- a/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts +++ b/web/features/deployments/deploy-drawer/state/__tests__/index.spec.ts @@ -13,9 +13,21 @@ import { PluginCategory, RuntimeInstanceStatus, } from '@dify/contracts/enterprise/types.gen' +import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' +type QueryOptions = { + data?: unknown + enabled?: boolean + input?: unknown + isError?: boolean + isFetching?: boolean + isLoading?: boolean + queryKey?: readonly unknown[] + retry?: boolean +} + type QueryResult = { data?: { options: DeploymentOptions @@ -62,9 +74,22 @@ const mockRollbackMutation = vi.hoisted<{ current: MutationResult }>(() => ({ })) vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => unknown) => atom((get) => { - createOptions(get) - return mockDeploymentOptionsQuery.current + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { + const options = createOptions(get) + if (options.queryKey?.[0] === 'computeDeploymentOptions') { + return { + ...options, + ...mockDeploymentOptionsQuery.current, + } + } + + return { + ...options, + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + } }), atomWithMutation: (createOptions: () => MutationOptions) => atom(() => { const options = createOptions() @@ -78,6 +103,13 @@ vi.mock('@/service/client', () => ({ consoleQuery: { enterprise: { releaseService: { + computeReleaseDeploymentView: { + queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({ + enabled, + input, + queryKey: ['computeReleaseDeploymentView', input], + }), + }, computeDeploymentOptions: { queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({ enabled, @@ -256,6 +288,23 @@ describe('deploy drawer state', () => { expect(store.get(state.deployDrawerReleaseIdAtom)).toBeUndefined() }) + it('should disable release deployment view query with skipToken until form app instance exists', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + + store.set(state.deployFormAppInstanceIdAtom, 'app-instance-1') + + expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + }) + it('should derive default environment and release selections from config', async () => { const state = await loadState() const store = createStore() diff --git a/web/features/deployments/deploy-drawer/state/index.ts b/web/features/deployments/deploy-drawer/state/index.ts index 1be5ce4c102..f0d37466567 100644 --- a/web/features/deployments/deploy-drawer/state/index.ts +++ b/web/features/deployments/deploy-drawer/state/index.ts @@ -11,21 +11,21 @@ import type { EnvVarBindingSlot, EnvVarValues, EnvVarValueSelection, -} from '../../components/env-var-bindings' -import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils' +} from '../../shared/components/env-var-bindings' +import type { RuntimeCredentialBindingSelections } from '../../shared/components/runtime-credential-bindings-utils' import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' -import { envVarBindingSlotFromContract } from '../../components/env-var-bindings-utils' +import { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils' import { hasMissingRequiredRuntimeCredentialBinding, runtimeCredentialSlotKey, selectedDeploymentRuntimeCredentials, selectedRuntimeCredentialSelections, -} from '../../components/runtime-credential-bindings-utils' +} from '../../shared/components/runtime-credential-bindings-utils' import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency' import { releaseDeploymentAction } from '../../shared/domain/release-action' @@ -39,7 +39,7 @@ export const deployDrawerOpenAtom = atom(false) export const deployDrawerAppInstanceIdAtom = atom(undefined) export const deployDrawerEnvironmentIdAtom = atom(undefined) export const deployDrawerReleaseIdAtom = atom(undefined) -export const deployFormAppInstanceIdAtom = atom('') +export const deployFormAppInstanceIdAtom = atom(undefined) export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => { set(deployDrawerAppInstanceIdAtom, params.appInstanceId) @@ -72,9 +72,11 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => { const appInstanceId = get(deployFormAppInstanceIdAtom) return consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({ - input: { - params: { appInstanceId }, - }, + input: appInstanceId + ? { + params: { appInstanceId }, + } + : skipToken, enabled: Boolean(appInstanceId), }) }) diff --git a/web/features/deployments/deploy-drawer/ui/form-sections.tsx b/web/features/deployments/deploy-drawer/ui/form-sections.tsx index d7d70bd81da..fa4f637503c 100644 --- a/web/features/deployments/deploy-drawer/ui/form-sections.tsx +++ b/web/features/deployments/deploy-drawer/ui/form-sections.tsx @@ -1,13 +1,13 @@ 'use client' import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen' -import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils' +import type { RuntimeCredentialBindingSelections } from '../../shared/components/runtime-credential-bindings-utils' import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton' -import { DeploymentStateMessage } from '../../components/empty-state' -import { RuntimeCredentialBindingsPanel } from '../../components/runtime-credential-bindings' +import { DeploymentStateMessage } from '../../shared/components/empty-state' +import { RuntimeCredentialBindingsPanel } from '../../shared/components/runtime-credential-bindings' import { formatDate, releaseCommit } from '../../shared/domain/release' import { deployDisplayedReleaseAtom, diff --git a/web/features/deployments/deploy-drawer/ui/form.tsx b/web/features/deployments/deploy-drawer/ui/form.tsx index b95bc871a56..6940c122380 100644 --- a/web/features/deployments/deploy-drawer/ui/form.tsx +++ b/web/features/deployments/deploy-drawer/ui/form.tsx @@ -9,7 +9,7 @@ import { Button } from '@langgenius/dify-ui/button' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' -import { EnvVarBindingsPanel } from '../../components/env-var-bindings' +import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings' import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status' import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state' import { diff --git a/web/features/deployments/deploy-drawer/ui/select.tsx b/web/features/deployments/deploy-drawer/ui/select.tsx index ec38d0e07f8..9fc8efa7f8d 100644 --- a/web/features/deployments/deploy-drawer/ui/select.tsx +++ b/web/features/deployments/deploy-drawer/ui/select.tsx @@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' -import { TitleTooltip } from '../../components/title-tooltip' +import { TitleTooltip } from '../../shared/components/title-tooltip' import { ModeBadge } from './status-badge' export function Field({ label, hint, children }: { diff --git a/web/features/deployments/deployment-actions/README.md b/web/features/deployments/deployment-actions/README.md new file mode 100644 index 00000000000..97b2f518cf9 --- /dev/null +++ b/web/features/deployments/deployment-actions/README.md @@ -0,0 +1,11 @@ +# Deployment Actions + +Action menu and edit/delete dialogs for deployment app instances. + +## Internal Modules + +None. + +## External Modules + +None. diff --git a/web/features/deployments/components/deployment-actions/__tests__/delete-dialog.spec.tsx b/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx similarity index 58% rename from web/features/deployments/components/deployment-actions/__tests__/delete-dialog.spec.tsx rename to web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx index dab22176f02..7f01287bece 100644 --- a/web/features/deployments/components/deployment-actions/__tests__/delete-dialog.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/delete-dialog.spec.tsx @@ -1,42 +1,13 @@ -import type { Getter } from 'jotai/vanilla' +import type { DeploymentActionAppInstance } from '../types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ScopeProvider } from 'jotai-scope' import { DeleteDeploymentDialog } from '../delete-dialog' import { deleteDeploymentDialogOpenAtom, - deploymentActionAppInstanceIdAtom, + deploymentActionAppInstanceAtom, } from '../state' -type QueryOptions = { - input?: unknown - queryKey?: readonly unknown[] -} - -type QueryResult = { - data?: unknown -} - -const mockQueryResults = vi.hoisted(() => ({ - current: new Map(), -})) - -const useQueryMock = vi.hoisted(() => - vi.fn((options: QueryOptions) => { - const queryName = String(options.queryKey?.[0] ?? 'unknown') - const queryResult = mockQueryResults.current.get(queryName) - - return { - ...options, - data: queryResult?.data, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: Boolean(queryResult?.data), - } - }), -) - const deleteMutationMock = vi.hoisted(() => ({ isPending: false, mutate: vi.fn(), @@ -58,16 +29,6 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn(), })) -vi.mock('jotai-tanstack-query', async () => { - const { atom } = await import('jotai') - - return { - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - return useQueryMock(createOptions(get)) - }), - } -}) - vi.mock('@tanstack/react-query', () => ({ useMutation: useMutationMock, })) @@ -84,12 +45,6 @@ vi.mock('@/service/client', () => ({ consoleQuery: { enterprise: { appInstanceService: { - getAppInstance: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getAppInstance', options.input], - }), - }, deleteAppInstance: { mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }), }, @@ -98,26 +53,26 @@ vi.mock('@/service/client', () => ({ }, })) -function setAppInstance() { - mockQueryResults.current.set('getAppInstance', { - data: { - appInstance: { - id: 'app-instance-1', - displayName: 'Deployment 1', - }, - }, - }) +function createAppInstance(overrides: Partial = {}): DeploymentActionAppInstance { + return { + id: 'app-instance-1', + displayName: 'Deployment 1', + description: 'Initial description', + ...overrides, + } } function renderDialog({ + appInstance = createAppInstance(), open = true, }: { + appInstance?: DeploymentActionAppInstance open?: boolean } = {}) { render( { beforeEach(() => { vi.clearAllMocks() - mockQueryResults.current.clear() deleteMutationMock.isPending = false - setAppInstance() }) describe('Delete action', () => { - it('should not mount the query or delete mutation before the dialog is opened', () => { + it('should not mount the delete mutation before the dialog is opened', () => { renderDialog({ open: false }) - expect(useQueryMock).not.toHaveBeenCalled() expect(useMutationMock).not.toHaveBeenCalled() }) diff --git a/web/features/deployments/components/deployment-actions/__tests__/edit-dialog.spec.tsx b/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx similarity index 56% rename from web/features/deployments/components/deployment-actions/__tests__/edit-dialog.spec.tsx rename to web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx index becc853a245..d74091a87f2 100644 --- a/web/features/deployments/components/deployment-actions/__tests__/edit-dialog.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/edit-dialog.spec.tsx @@ -1,44 +1,13 @@ -import type { Getter } from 'jotai/vanilla' +import type { DeploymentActionAppInstance } from '../types' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ScopeProvider } from 'jotai-scope' import { EditDeploymentDialog } from '../edit-dialog' import { - deploymentActionAppInstanceIdAtom, + deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom, } from '../state' -type QueryOptions = { - input?: unknown - queryKey?: readonly unknown[] -} - -type QueryResult = { - data?: unknown - isError?: boolean - isLoading?: boolean -} - -const mockQueryResults = vi.hoisted(() => ({ - current: new Map(), -})) - -const useQueryMock = vi.hoisted(() => - vi.fn((options: QueryOptions) => { - const queryName = String(options.queryKey?.[0] ?? 'unknown') - const queryResult = mockQueryResults.current.get(queryName) - - return { - ...options, - data: queryResult?.data, - isError: queryResult?.isError ?? false, - isFetching: false, - isLoading: queryResult?.isLoading ?? false, - isSuccess: Boolean(queryResult?.data), - } - }), -) - const updateMutationMock = vi.hoisted(() => ({ isPending: false, mutate: vi.fn(), @@ -56,16 +25,6 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn(), })) -vi.mock('jotai-tanstack-query', async () => { - const { atom } = await import('jotai') - - return { - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { - return useQueryMock(createOptions(get)) - }), - } -}) - vi.mock('@tanstack/react-query', () => ({ useMutation: useMutationMock, })) @@ -78,51 +37,34 @@ vi.mock('@/service/client', () => ({ consoleQuery: { enterprise: { appInstanceService: { - getAppInstance: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getAppInstance', options.input], - }), - }, updateAppInstance: { mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }), }, - deleteAppInstance: { - mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }), - }, }, }, }, })) -function setAppInstance(overrides: Record = {}) { - mockQueryResults.current.set('getAppInstance', { - data: { - appInstance: { - id: 'app-instance-1', - displayName: 'Deployment 1', - description: 'Initial description', - ...overrides, - }, - }, - }) -} - -function setAppInstanceLoading() { - mockQueryResults.current.set('getAppInstance', { - isLoading: true, - }) +function createAppInstance(overrides: Partial = {}): DeploymentActionAppInstance { + return { + id: 'app-instance-1', + displayName: 'Deployment 1', + description: 'Initial description', + ...overrides, + } } function renderDialog({ + appInstance = createAppInstance(), open = true, }: { + appInstance?: DeploymentActionAppInstance open?: boolean } = {}) { render( { beforeEach(() => { vi.clearAllMocks() - mockQueryResults.current.clear() updateMutationMock.isPending = false - setAppInstance() }) describe('Form submission', () => { - it('should not mount the query or update mutation before the dialog is opened', () => { + it('should not mount the update mutation before the dialog is opened', () => { renderDialog({ open: false }) - expect(useQueryMock).not.toHaveBeenCalled() expect(useMutationMock).not.toHaveBeenCalled() }) - it('should create the update mutation only after the edit form is ready', () => { - setAppInstanceLoading() - + it('should render form values from the passed app instance', () => { renderDialog() - expect(useMutationMock).not.toHaveBeenCalled() + const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' }) + expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' })).toHaveValue('Deployment 1') + expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' })).toHaveValue('Initial description') }) it('should submit trimmed deployment metadata through the component mutation', async () => { diff --git a/web/features/deployments/components/deployment-actions/index.spec.tsx b/web/features/deployments/deployment-actions/__tests__/index.spec.tsx similarity index 57% rename from web/features/deployments/components/deployment-actions/index.spec.tsx rename to web/features/deployments/deployment-actions/__tests__/index.spec.tsx index 410f3b6c56a..0e0bbb8fe89 100644 --- a/web/features/deployments/components/deployment-actions/index.spec.tsx +++ b/web/features/deployments/deployment-actions/__tests__/index.spec.tsx @@ -1,66 +1,51 @@ +import type { DeploymentActionAppInstance } from '../types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DeploymentActionsMenu } from './index' - -type QueryOptions = { - input?: unknown - queryKey?: readonly unknown[] -} +import { DeploymentActionsMenu } from '../index' const editDialogMock = vi.hoisted(() => vi.fn()) const deleteDialogMock = vi.hoisted(() => vi.fn()) -const prefetchQueryMock = vi.hoisted(() => vi.fn()) -vi.mock('@tanstack/react-query', () => ({ - useQueryClient: () => ({ - prefetchQuery: prefetchQueryMock, - }), -})) - -vi.mock('@/service/client', () => ({ - consoleQuery: { - enterprise: { - appInstanceService: { - getAppInstance: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getAppInstance', options.input], - }), - }, - }, - }, - }, -})) - -vi.mock('./edit-dialog', async () => { +vi.mock('../edit-dialog', async () => { const { useAtomValue } = await import('jotai') - const { editDeploymentDialogOpenAtom } = await import('./state') + const { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } = await import('../state') return { EditDeploymentDialog: () => { const open = useAtomValue(editDeploymentDialogOpenAtom) - editDialogMock({ open }) + const appInstance = useAtomValue(deploymentActionAppInstanceAtom) + editDialogMock({ appInstanceId: appInstance.id, open }) return
}, } }) -vi.mock('./delete-dialog', async () => { +vi.mock('../delete-dialog', async () => { const { useAtomValue } = await import('jotai') - const { deleteDeploymentDialogOpenAtom } = await import('./state') + const { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } = await import('../state') return { DeleteDeploymentDialog: () => { const open = useAtomValue(deleteDeploymentDialogOpenAtom) - deleteDialogMock({ open }) + const appInstance = useAtomValue(deploymentActionAppInstanceAtom) + deleteDialogMock({ appInstanceId: appInstance.id, open }) return
}, } }) +function createAppInstance(overrides: Partial = {}): DeploymentActionAppInstance { + return { + id: 'app-instance-1', + displayName: 'Deployment 1', + description: 'Initial description', + ...overrides, + } +} + describe('DeploymentActionsMenu', () => { beforeEach(() => { vi.clearAllMocks() @@ -69,7 +54,7 @@ describe('DeploymentActionsMenu', () => { it('keeps the trigger wrapper visible through uncontrolled menu state', () => { const { container } = render( , @@ -84,41 +69,12 @@ describe('DeploymentActionsMenu', () => { ) }) - it('prefetches the app instance when the menu opens', async () => { - const user = userEvent.setup() - - render( - , - ) - - expect(prefetchQueryMock).not.toHaveBeenCalled() - - await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' })) - await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' }) - - expect(prefetchQueryMock).toHaveBeenCalledWith(expect.objectContaining({ - input: { - params: { - appInstanceId: 'app-instance-1', - }, - }, - queryKey: ['getAppInstance', { - params: { - appInstanceId: 'app-instance-1', - }, - }], - })) - }) - it('opens edit and delete dialogs from menu items', async () => { const user = userEvent.setup() render( , ) @@ -137,7 +93,7 @@ describe('DeploymentActionsMenu', () => { expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false') expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true') - expect(editDialogMock).toHaveBeenLastCalledWith({ open: false }) - expect(deleteDialogMock).toHaveBeenLastCalledWith({ open: true }) + expect(editDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: false }) + expect(deleteDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: true }) }) }) diff --git a/web/features/deployments/components/deployment-actions/delete-dialog.tsx b/web/features/deployments/deployment-actions/delete-dialog.tsx similarity index 88% rename from web/features/deployments/components/deployment-actions/delete-dialog.tsx rename to web/features/deployments/deployment-actions/delete-dialog.tsx index aea7313f3a6..35354d1a28f 100644 --- a/web/features/deployments/components/deployment-actions/delete-dialog.tsx +++ b/web/features/deployments/deployment-actions/delete-dialog.tsx @@ -17,24 +17,22 @@ import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { deleteDeploymentDialogOpenAtom, - deploymentActionAppInstanceIdAtom, - deploymentActionAppInstanceQueryAtom, + deploymentActionAppInstanceAtom, } from './state' function DeleteDeploymentDialogContent() { const { t } = useTranslation('deployments') const router = useRouter() - const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom) + const appInstance = useAtomValue(deploymentActionAppInstanceAtom) const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom) - const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom) const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions()) - const displayName = instanceQuery.data?.appInstance.displayName || appInstanceId + const displayName = appInstance.displayName || appInstance.id function handleDelete() { deleteInstance.mutate( { params: { - appInstanceId, + appInstanceId: appInstance.id, }, }, { diff --git a/web/features/deployments/components/deployment-actions/edit-dialog.tsx b/web/features/deployments/deployment-actions/edit-dialog.tsx similarity index 70% rename from web/features/deployments/components/deployment-actions/edit-dialog.tsx rename to web/features/deployments/deployment-actions/edit-dialog.tsx index 538ec33cb92..e73143a09fc 100644 --- a/web/features/deployments/components/deployment-actions/edit-dialog.tsx +++ b/web/features/deployments/deployment-actions/edit-dialog.tsx @@ -14,11 +14,9 @@ import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' -import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { consoleQuery } from '@/service/client' import { - deploymentActionAppInstanceIdAtom, - deploymentActionAppInstanceQueryAtom, + deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom, } from './state' @@ -46,33 +44,15 @@ function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, va ) } -function EditDeploymentFormSkeleton() { - return ( -
-
- - -
-
- - -
- - - - -
- ) -} - -function EditDeploymentForm({ - initialValues, -}: { - initialValues: EditDeploymentFormValues -}) { +function EditDeploymentForm() { const { t } = useTranslation('deployments') const nameLabel = t('settings.name') - const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom) + const appInstance = useAtomValue(deploymentActionAppInstanceAtom) + const appInstanceId = appInstance.id + const initialValues = { + name: appInstance.displayName, + description: appInstance.description, + } const setOpen = useSetAtom(editDeploymentDialogOpenAtom) const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions()) @@ -162,33 +142,19 @@ function EditDeploymentForm({ function EditDeploymentDialogContent() { const { t } = useTranslation('deployments') - const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom) - const app = instanceQuery.data?.appInstance + const appInstance = useAtomValue(deploymentActionAppInstanceAtom) return ( <> - {!app && }
{t('card.menu.editInfo')}
- {instanceQuery.isLoading - ? - : instanceQuery.isError - ?
{t('common.loadFailed')}
- : app - ? ( - - ) - :
{t('detail.notFound')}
} +
) diff --git a/web/features/deployments/components/deployment-actions/index.tsx b/web/features/deployments/deployment-actions/index.tsx similarity index 82% rename from web/features/deployments/components/deployment-actions/index.tsx rename to web/features/deployments/deployment-actions/index.tsx index 70a6c677e92..2cdf4e8bd7e 100644 --- a/web/features/deployments/components/deployment-actions/index.tsx +++ b/web/features/deployments/deployment-actions/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { ComponentProps } from 'react' +import type { DeploymentActionAppInstance } from './types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -9,15 +10,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { useQueryClient } from '@tanstack/react-query' -import { useAtomValue, useSetAtom } from 'jotai' +import { useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' import { DeleteDeploymentDialog } from './delete-dialog' import { EditDeploymentDialog } from './edit-dialog' import { - deploymentActionAppInstanceIdAtom, - deploymentActionAppInstanceQueryOptionsAtom, + deploymentActionAppInstanceAtom, deploymentActionsLocalAtoms, openDeleteDeploymentDialogAtom, openEditDeploymentDialogAtom, @@ -30,7 +29,7 @@ const ACTION_TRIGGER_CLASS_NAME = cn( ) type DeploymentActionsMenuProps = { - appInstanceId: string + appInstance: DeploymentActionAppInstance className?: string triggerClassName?: string placement: ComponentProps['placement'] @@ -42,18 +41,11 @@ function DeploymentActionsMenuContent({ triggerClassName, placement, sideOffset, -}: Omit) { +}: Omit) { const { t } = useTranslation('deployments') - const queryClient = useQueryClient() - const appInstanceQueryOptions = useAtomValue(deploymentActionAppInstanceQueryOptionsAtom) const openEditDialog = useSetAtom(openEditDeploymentDialogAtom) const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom) - function handleMenuOpenChange(open: boolean) { - if (open) - void queryClient.prefetchQuery(appInstanceQueryOptions) - } - return (
event.stopPropagation()} onKeyDown={event => event.stopPropagation()} > - + (() => { + throw new Error('Missing deployment action app instance.') +}) + +export const editDeploymentDialogOpenAtom = atom(false) +export const deleteDeploymentDialogOpenAtom = atom(false) + +export const openEditDeploymentDialogAtom = atom(null, (_get, set) => { + set(deleteDeploymentDialogOpenAtom, false) + set(editDeploymentDialogOpenAtom, true) +}) + +export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => { + set(editDeploymentDialogOpenAtom, false) + set(deleteDeploymentDialogOpenAtom, true) +}) + +export const deploymentActionsLocalAtoms = [ + editDeploymentDialogOpenAtom, + deleteDeploymentDialogOpenAtom, +] as const diff --git a/web/features/deployments/deployment-actions/types.ts b/web/features/deployments/deployment-actions/types.ts new file mode 100644 index 00000000000..48f1c180f26 --- /dev/null +++ b/web/features/deployments/deployment-actions/types.ts @@ -0,0 +1,3 @@ +import type { AppInstance } from '@dify/contracts/enterprise/types.gen' + +export type DeploymentActionAppInstance = Pick diff --git a/web/features/deployments/detail/README.md b/web/features/deployments/detail/README.md new file mode 100644 index 00000000000..e1bfa0a99ce --- /dev/null +++ b/web/features/deployments/detail/README.md @@ -0,0 +1,20 @@ +# Deployment Detail Shell + +Detail shell for the deployment app instance routes, including sidebar navigation, route headers, and route-level action slots. + +## Internal Modules + +| Module | Why this module uses it | +| --------------------------------- | ------------------------------------------------------------------------------ | +| `create-release` | Opens release creation from the detail header. | +| `deployment-actions` | Reuses app instance action menu behavior in the detail sidebar. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads route identity used by detail query and tab atoms. | +| `detail/api-tokens` | Renders the developer API header switch for the API tokens route. | +| `detail/instances/header-actions` | Renders the new deployment header action for the instances route. | + +## External Modules + +| Module | Why this module uses it | +| ------------------------------------ | ----------------------------------------------------------------- | +| `app/components/goto-anything/atoms` | Opens the global search command from the detail sidebar shortcut. | diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index bf2694d178c..f7812000655 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -1,25 +1,37 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import type { Getter } from 'jotai' +import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { + data?: unknown enabled?: boolean input?: unknown queryKey?: readonly unknown[] refetchInterval?: (query: { state: { data?: unknown } }) => number | false } +const mockEnvironmentDeploymentsData = vi.hoisted<{ + current?: { environmentDeployments: EnvironmentDeployment[] } +}>(() => ({})) + vi.mock('jotai-tanstack-query', () => ({ - atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ - ...createOptions(get), - data: undefined, - isError: false, - isFetching: false, - isLoading: false, - isSuccess: false, - })), + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { + const options = createOptions(get) + return { + ...options, + data: options.queryKey?.[0] === 'listEnvironmentDeployments' + ? mockEnvironmentDeploymentsData.current + : undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + } + }), })) vi.mock('@/service/client', () => ({ @@ -32,12 +44,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['getAppInstance', options.input], }), }, - getAppInstanceOverview: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getAppInstanceOverview', options.input], - }), - }, }, deploymentService: { listEnvironmentDeployments: { @@ -55,14 +61,36 @@ async function loadState() { return await import('../state') } -function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1', tab = 'overview') { store.set(setNextRouteStateAtom, { - pathname: `/deployments/${appInstanceId}/overview`, + pathname: `/deployments/${appInstanceId}/${tab}`, params: { appInstanceId }, }) } +function deploymentRow(id: string, overrides: Partial = {}) { + return { + environment: { + id, + displayName: id, + }, + status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY, + currentRelease: { + id: `release-${id}`, + }, + desiredRelease: undefined, + currentDeployment: { + id: `deployment-${id}`, + }, + ...overrides, + } as EnvironmentDeployment +} + describe('deployment detail state', () => { + beforeEach(() => { + mockEnvironmentDeploymentsData.current = undefined + }) + it('should disable detail queries with skipToken until a route app instance exists', async () => { const state = await loadState() const store = createStore() @@ -71,10 +99,6 @@ describe('deployment detail state', () => { enabled: false, input: skipToken, }) - expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({ - enabled: false, - input: skipToken, - }) expect(store.get(state.deploymentEnvironmentDeploymentsQueryAtom)).toMatchObject({ enabled: false, input: skipToken, @@ -91,10 +115,6 @@ describe('deployment detail state', () => { enabled: true, input: { params: { appInstanceId: 'app-instance-1' } }, }) - expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({ - enabled: true, - input: { params: { appInstanceId: 'app-instance-1' } }, - }) const environmentDeploymentsQuery = store.get(state.deploymentEnvironmentDeploymentsQueryAtom) as unknown as QueryOptions expect(environmentDeploymentsQuery).toMatchObject({ @@ -103,4 +123,33 @@ describe('deployment detail state', () => { }) expect(environmentDeploymentsQuery.refetchInterval).toEqual(expect.any(Function)) }) + + it('should derive active detail tab from route pathname', async () => { + const state = await loadState() + const store = createStore() + + setDeploymentRoute(store, 'app-instance-1', 'releases') + expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('releases') + + setDeploymentRoute(store, 'app-instance-1', 'unknown') + expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('overview') + }) + + it('should derive runtime instance rows from environment deployments', async () => { + const state = await loadState() + const store = createStore() + mockEnvironmentDeploymentsData.current = { + environmentDeployments: [ + deploymentRow('running'), + deploymentRow('undeployed', { + status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED, + currentRelease: undefined, + desiredRelease: undefined, + currentDeployment: undefined, + }), + ], + } + + expect(store.get(state.deploymentRuntimeInstanceRowsAtom).map(row => row.environment.id)).toEqual(['running']) + }) }) diff --git a/web/features/deployments/detail/access/README.md b/web/features/deployments/detail/access/README.md new file mode 100644 index 00000000000..c115b2faf94 --- /dev/null +++ b/web/features/deployments/detail/access/README.md @@ -0,0 +1,15 @@ +# Deployment Access + +Access route composition for webapp channels and environment permission sections. + +## Internal Modules + +| Module | Why this module uses it | +| --------------------------- | ------------------------------------------------------------------ | +| `route-state` | Reads the route app instance identity for access settings queries. | +| `detail/access/channels` | Renders access channel endpoint settings. | +| `detail/access/permissions` | Renders environment access policy settings. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/access-tab/__tests__/state.spec.ts b/web/features/deployments/detail/access/__tests__/state.spec.ts similarity index 77% rename from web/features/deployments/detail/access-tab/__tests__/state.spec.ts rename to web/features/deployments/detail/access/__tests__/state.spec.ts index 65ec27d8773..a989a98618d 100644 --- a/web/features/deployments/detail/access-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/access/__tests__/state.spec.ts @@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({ queryKey: ['getAccessSettings', options.input], }), }, - getDeveloperApiSettings: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getDeveloperApiSettings', options.input], - }), - }, }, }, }, @@ -62,10 +56,6 @@ describe('deployment access state', () => { enabled: false, input: skipToken, }) - expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ - enabled: false, - input: skipToken, - }) setDeploymentRoute(store) @@ -73,9 +63,5 @@ describe('deployment access state', () => { enabled: true, input: { params: { appInstanceId: 'app-instance-1' } }, }) - expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ - enabled: true, - input: { params: { appInstanceId: 'app-instance-1' } }, - }) }) }) diff --git a/web/features/deployments/detail/access/channels/README.md b/web/features/deployments/detail/access/channels/README.md new file mode 100644 index 00000000000..bd23f161fc9 --- /dev/null +++ b/web/features/deployments/detail/access/channels/README.md @@ -0,0 +1,17 @@ +# Deployment Access Channels + +Access channels section for viewing webapp and developer API endpoints and toggling webapp access. + +## Internal Modules + +| Module | Why this module uses it | +| --------------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for access-channel mutations. | +| `detail/access` | Reads access route query data for channel settings. | + +## External Modules + +| Module | Why this module uses it | +| ------------------------------ | --------------------------------------------------------------- | +| `app/components/base/skeleton` | Reuses skeleton primitives for channel endpoint loading states. | diff --git a/web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx b/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/channels/__tests__/section.spec.tsx rename to web/features/deployments/detail/access/channels/__tests__/section.spec.tsx diff --git a/web/features/deployments/detail/access-tab/channels/section.tsx b/web/features/deployments/detail/access/channels/section.tsx similarity index 98% rename from web/features/deployments/detail/access-tab/channels/section.tsx rename to web/features/deployments/detail/access/channels/section.tsx index 5f08cc032c3..affd031b790 100644 --- a/web/features/deployments/detail/access-tab/channels/section.tsx +++ b/web/features/deployments/detail/access/channels/section.tsx @@ -8,10 +8,10 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { consoleQuery } from '@/service/client' -import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { Section } from '../../components/section' -import { CopyPill, EndpointRow } from '../components/endpoint' +import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { CopyPill, EndpointRow } from '../../../shared/components/endpoint' +import { Section } from '../../../shared/components/section' import { accessSettingsQueryAtom } from '../state' import { getUrlOrigin } from './url' diff --git a/web/features/deployments/detail/access-tab/channels/url.ts b/web/features/deployments/detail/access/channels/url.ts similarity index 100% rename from web/features/deployments/detail/access-tab/channels/url.ts rename to web/features/deployments/detail/access/channels/url.ts diff --git a/web/features/deployments/detail/access-tab/index.tsx b/web/features/deployments/detail/access/index.tsx similarity index 90% rename from web/features/deployments/detail/access-tab/index.tsx rename to web/features/deployments/detail/access/index.tsx index 88f28cf6a8b..39f469a8013 100644 --- a/web/features/deployments/detail/access-tab/index.tsx +++ b/web/features/deployments/detail/access/index.tsx @@ -3,7 +3,7 @@ import { AccessChannelsSection } from './channels/section' import { AccessPermissionsSection } from './permissions/section' -export function AccessTab() { +export function DeploymentAccess() { return (
diff --git a/web/features/deployments/detail/access/permissions/README.md b/web/features/deployments/detail/access/permissions/README.md new file mode 100644 index 00000000000..7698db4dd9f --- /dev/null +++ b/web/features/deployments/detail/access/permissions/README.md @@ -0,0 +1,18 @@ +# Deployment Access Permissions + +Environment permission section for viewing and editing access policies on deployment environments. + +## Internal Modules + +| Module | Why this module uses it | +| --------------------------------------------------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for access-policy mutations. | +| `detail/access` | Reads access route query data for environment access policies. | +| `detail/access/permissions/access-subject-selector` | Selects specific members and groups for access policies. | + +## External Modules + +| Module | Why this module uses it | +| ------------------------------ | --------------------------------------------------------- | +| `app/components/base/skeleton` | Reuses skeleton primitives for permission loading states. | diff --git a/web/features/deployments/detail/access-tab/permissions/__tests__/access-control-dialog.spec.tsx b/web/features/deployments/detail/access/permissions/__tests__/access-control-dialog.spec.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/__tests__/access-control-dialog.spec.tsx rename to web/features/deployments/detail/access/permissions/__tests__/access-control-dialog.spec.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/__tests__/access-policy.spec.ts b/web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/__tests__/access-policy.spec.ts rename to web/features/deployments/detail/access/permissions/__tests__/access-policy.spec.ts diff --git a/web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx b/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/__tests__/permissions.spec.tsx rename to web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx b/web/features/deployments/detail/access/permissions/access-control-dialog.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-control-dialog.tsx rename to web/features/deployments/detail/access/permissions/access-control-dialog.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-policy.ts b/web/features/deployments/detail/access/permissions/access-policy.ts similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-policy.ts rename to web/features/deployments/detail/access/permissions/access-policy.ts diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/README.md b/web/features/deployments/detail/access/permissions/access-subject-selector/README.md new file mode 100644 index 00000000000..b24197f788e --- /dev/null +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/README.md @@ -0,0 +1,17 @@ +# Deployment Access Subject Selector + +Subject selector used by access permission dialogs to search, add, and display members or groups. + +## Internal Modules + +None. + +## External Modules + +| Module | Why this module uses it | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| `app/components/base/loading` | Reuses the inline loading indicator while access-subject search is pending. | +| `app/components/base/skeleton` | Reuses skeleton primitives for selected subject loading rows. | +| `context/app-context` | Reads the current user id to label the current user in selectable subject options. | +| `models/access-control` | Reuses access-control subject types and group models for selector values. | +| `service/access-control/use-access-subjects` | Searches users and groups for access-policy subject selection. | diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/__tests__/add-button.spec.tsx rename to web/features/deployments/detail/access/permissions/access-subject-selector/__tests__/add-button.spec.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/add-button.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/add-button.tsx rename to web/features/deployments/detail/access/permissions/access-subject-selector/add-button.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/selection-list.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/selection-list.tsx rename to web/features/deployments/detail/access/permissions/access-subject-selector/selection-list.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/subject-options.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/subject-options.tsx rename to web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/types.ts b/web/features/deployments/detail/access/permissions/access-subject-selector/types.ts similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/types.ts rename to web/features/deployments/detail/access/permissions/access-subject-selector/types.ts diff --git a/web/features/deployments/detail/access-tab/permissions/access-subject-selector/utils.ts b/web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/access-subject-selector/utils.ts rename to web/features/deployments/detail/access/permissions/access-subject-selector/utils.ts diff --git a/web/features/deployments/detail/access-tab/permissions/environment-permission-row.tsx b/web/features/deployments/detail/access/permissions/environment-permission-row.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/environment-permission-row.tsx rename to web/features/deployments/detail/access/permissions/environment-permission-row.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/permission-summary-button.tsx b/web/features/deployments/detail/access/permissions/permission-summary-button.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/permissions/permission-summary-button.tsx rename to web/features/deployments/detail/access/permissions/permission-summary-button.tsx diff --git a/web/features/deployments/detail/access-tab/permissions/section.tsx b/web/features/deployments/detail/access/permissions/section.tsx similarity index 96% rename from web/features/deployments/detail/access-tab/permissions/section.tsx rename to web/features/deployments/detail/access/permissions/section.tsx index 4243ec6db45..c1153c36bc6 100644 --- a/web/features/deployments/detail/access-tab/permissions/section.tsx +++ b/web/features/deployments/detail/access/permissions/section.tsx @@ -4,9 +4,9 @@ import type { EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.g import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle } from '@/app/components/base/skeleton' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { Section } from '../../components/section' +import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { Section } from '../../../shared/components/section' import { accessSettingsQueryAtom } from '../state' import { EnvironmentPermissionRow } from './environment-permission-row' diff --git a/web/features/deployments/detail/access/state.ts b/web/features/deployments/detail/access/state.ts new file mode 100644 index 00000000000..620678f9496 --- /dev/null +++ b/web/features/deployments/detail/access/state.ts @@ -0,0 +1,19 @@ +'use client' + +import { skipToken } from '@tanstack/react-query' +import { atomWithQuery } from 'jotai-tanstack-query' +import { consoleQuery } from '@/service/client' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' + +export const accessSettingsQueryAtom = atomWithQuery((get) => { + const appInstanceId = get(deploymentRouteAppInstanceIdAtom) + + return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({ + input: appInstanceId + ? { + params: { appInstanceId }, + } + : skipToken, + enabled: Boolean(appInstanceId), + }) +}) diff --git a/web/features/deployments/detail/api-tokens/README.md b/web/features/deployments/detail/api-tokens/README.md new file mode 100644 index 00000000000..8559dd6a3e8 --- /dev/null +++ b/web/features/deployments/detail/api-tokens/README.md @@ -0,0 +1,14 @@ +# Deployment API Tokens + +API tokens route for API token management, endpoint display, and API documentation drawer. + +## Internal Modules + +| Module | Why this module uses it | +| ---------------------------------------- | -------------------------------------------------------------------------- | +| `route-state` | Reads the route app instance identity for API token queries. | +| `detail/api-tokens/api-token-management` | Renders API endpoint display, API key creation, and API docs entry points. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts b/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts new file mode 100644 index 00000000000..fe2d8044d47 --- /dev/null +++ b/web/features/deployments/detail/api-tokens/__tests__/state.spec.ts @@ -0,0 +1,67 @@ +import type { Getter } from 'jotai' +import { skipToken } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryKey?: readonly unknown[] +} + +vi.mock('jotai-tanstack-query', () => ({ + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + accessService: { + getDeveloperApiSettings: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['getDeveloperApiSettings', options.input], + }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../state') +} + +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/api-tokens`, + params: { appInstanceId }, + }) +} + +describe('deployment API tokens state', () => { + it('should gate API token queries until a route app instance exists', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + + setDeploymentRoute(store) + + expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + }) +}) diff --git a/web/features/deployments/detail/api-tokens/api-keys/README.md b/web/features/deployments/detail/api-tokens/api-keys/README.md new file mode 100644 index 00000000000..30efc77723c --- /dev/null +++ b/web/features/deployments/detail/api-tokens/api-keys/README.md @@ -0,0 +1,15 @@ +# Deployment API Keys + +API key creation, listing, and generated token presentation for the API tokens route. + +## Internal Modules + +| Module | Why this module uses it | +| ------------------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for API key mutations. | +| `detail/api-tokens` | Uses API-token-owned query state and table styles. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/access-tab/developer-api/__tests__/api-key-generate-menu.spec.tsx b/web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/developer-api/__tests__/api-key-generate-menu.spec.tsx rename to web/features/deployments/detail/api-tokens/api-keys/__tests__/api-key-generate-menu.spec.tsx diff --git a/web/features/deployments/detail/access-tab/developer-api/api-key-generate-menu.tsx b/web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/developer-api/api-key-generate-menu.tsx rename to web/features/deployments/detail/api-tokens/api-keys/api-key-generate-menu.tsx diff --git a/web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx b/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx similarity index 98% rename from web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx rename to web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx index 4bcbc9152c1..bcb8c8e536d 100644 --- a/web/features/deployments/detail/access-tab/developer-api/api-key-list.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/api-key-list.tsx @@ -28,10 +28,8 @@ import { DetailTableHead, DetailTableHeader, DetailTableRow, -} from '../../components/detail-table' -import { - API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES, -} from '../../components/detail-table-styles' +} from '../../../shared/components/detail-table' +import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles' function ApiKeyName({ apiKey }: { apiKey: ApiKey diff --git a/web/features/deployments/detail/access-tab/developer-api/api-token-name.ts b/web/features/deployments/detail/api-tokens/api-keys/api-token-name.ts similarity index 100% rename from web/features/deployments/detail/access-tab/developer-api/api-token-name.ts rename to web/features/deployments/detail/api-tokens/api-keys/api-token-name.ts diff --git a/web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx b/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx similarity index 98% rename from web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx rename to web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx index 16778ec84fc..0f66f5d941c 100644 --- a/web/features/deployments/detail/access-tab/developer-api/created-token-dialog.tsx +++ b/web/features/deployments/detail/api-tokens/api-keys/created-token-dialog.tsx @@ -6,7 +6,7 @@ import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitl import { toast } from '@langgenius/dify-ui/toast' import { useClipboard } from 'foxact/use-clipboard' import { useTranslation } from 'react-i18next' -import { CopyPill } from '../components/endpoint' +import { CopyPill } from '../../../shared/components/endpoint' function buildCurlExample(apiUrl: string, token: string) { return `curl -X POST '${apiUrl}' \\ diff --git a/web/features/deployments/detail/api-tokens/api-token-management/README.md b/web/features/deployments/detail/api-tokens/api-token-management/README.md new file mode 100644 index 00000000000..196f5586ffe --- /dev/null +++ b/web/features/deployments/detail/api-tokens/api-token-management/README.md @@ -0,0 +1,17 @@ +# Deployment API Token Management + +API token management section for API endpoint display, token creation, and token listing. + +## Internal Modules + +| Module | Why this module uses it | +| ---------------------------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for API token mutations. | +| `detail/api-tokens` | Reads API tokens route query data and table styles. | +| `detail/api-tokens/api-keys` | Renders API key creation, listing, and created-token surfaces. | +| `detail/api-tokens/docs` | Opens API documentation for the current endpoint. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/access-tab/developer-api/section.tsx b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx similarity index 70% rename from web/features/deployments/detail/access-tab/developer-api/section.tsx rename to web/features/deployments/detail/api-tokens/api-token-management/section.tsx index 503ca1dadf6..34eb480a9de 100644 --- a/web/features/deployments/detail/access-tab/developer-api/section.tsx +++ b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx @@ -1,26 +1,22 @@ 'use client' import type { - AccessChannels, ApiKey, Environment, } from '@dify/contracts/enterprise/types.gen' import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' -import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch' -import { useMutation } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state' import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' -import { CopyPill } from '../components/endpoint' +import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state' +import { CopyPill } from '../../../shared/components/endpoint' +import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu' +import { ApiKeyList } from '../api-keys/api-key-list' +import { CreatedApiTokenDialog } from '../api-keys/created-token-dialog' +import { DeveloperApiDocsDrawer } from '../docs/docs-drawer' import { developerApiSettingsQueryAtom } from '../state' -import { ApiKeyGenerateMenu } from './api-key-generate-menu' -import { ApiKeyList } from './api-key-list' -import { CreatedApiTokenDialog } from './created-token-dialog' -import { DeveloperApiDocsDrawer } from './docs-drawer' import { DeveloperApiSkeleton } from './skeleton' type CreatedApiToken = { @@ -28,61 +24,6 @@ type CreatedApiToken = { token: string } -function DeveloperApiSwitch({ checked, accessChannels, disabled }: { - checked: boolean - accessChannels?: AccessChannels - disabled?: boolean -}) { - const { t } = useTranslation('deployments') - const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) - const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) - - return ( - { - if (!appInstanceId) - return - - toggleDeveloperAPI.mutate({ - params: { appInstanceId }, - body: { - appInstanceId, - webAppEnabled: accessChannels?.webAppEnabled ?? false, - developerApiEnabled: enabled, - }, - }) - }} - /> - ) -} - -export function DeveloperApiHeaderSwitch() { - const { t } = useTranslation('deployments') - const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom) - const accessChannels = developerApiSettingsQuery.data?.accessChannels - const apiEnabled = accessChannels?.developerApiEnabled ?? false - - if (developerApiSettingsQuery.isLoading) - return - - return ( -
- - {apiEnabled ? t('overview.enabled') : t('overview.disabled')} - - -
- ) -} - function ApiKeyListSection({ apiKeys, environments, action }: { apiKeys: ApiKey[] environments: Environment[] diff --git a/web/features/deployments/detail/access-tab/developer-api/skeleton.tsx b/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx similarity index 96% rename from web/features/deployments/detail/access-tab/developer-api/skeleton.tsx rename to web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx index d37d26719b8..a1d1a5b8e55 100644 --- a/web/features/deployments/detail/access-tab/developer-api/skeleton.tsx +++ b/web/features/deployments/detail/api-tokens/api-token-management/skeleton.tsx @@ -11,8 +11,8 @@ import { DetailTableHead, DetailTableHeader, DetailTableRow, -} from '../../components/detail-table' -import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles' +} from '../../../shared/components/detail-table' +import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles' const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key'] diff --git a/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx new file mode 100644 index 00000000000..9415754ab5e --- /dev/null +++ b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { AccessChannels } from '@dify/contracts/enterprise/types.gen' +import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch' +import { useMutation } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' +import { developerApiSettingsQueryAtom } from './state' + +function DeveloperApiSwitch({ checked, accessChannels, disabled }: { + checked: boolean + accessChannels?: AccessChannels + disabled?: boolean +}) { + const { t } = useTranslation('deployments') + const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) + const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions()) + + return ( + { + if (!appInstanceId) + return + + toggleDeveloperAPI.mutate({ + params: { appInstanceId }, + body: { + appInstanceId, + webAppEnabled: accessChannels?.webAppEnabled ?? false, + developerApiEnabled: enabled, + }, + }) + }} + /> + ) +} + +export function DeveloperApiHeaderSwitch() { + const { t } = useTranslation('deployments') + const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom) + const accessChannels = developerApiSettingsQuery.data?.accessChannels + const apiEnabled = accessChannels?.developerApiEnabled ?? false + + if (developerApiSettingsQuery.isLoading) + return + + return ( +
+ + {apiEnabled ? t('overview.enabled') : t('overview.disabled')} + + +
+ ) +} diff --git a/web/features/deployments/detail/api-tokens/docs/README.md b/web/features/deployments/detail/api-tokens/docs/README.md new file mode 100644 index 00000000000..a8b91cdec53 --- /dev/null +++ b/web/features/deployments/detail/api-tokens/docs/README.md @@ -0,0 +1,19 @@ +# Deployment API Token Docs + +API documentation drawer for the API tokens route. + +## Internal Modules + +| Module | Why this module uses it | +| ------------- | ------------------------------------------------------------- | +| `route-state` | Reads the route app instance identity for documentation copy. | + +## External Modules + +| Module | Why this module uses it | +| --------------------------------- | ----------------------------------------------------------- | +| `app/components/develop/template` | Reuses localized workflow API documentation templates. | +| `context/i18n` | Reads the current locale for documentation template choice. | +| `hooks/use-theme` | Reads the current theme for documentation rendering. | +| `i18n-config/language` | Maps the current locale to the documentation language. | +| `types/app` | Uses app and theme enums for documentation rendering. | diff --git a/web/features/deployments/detail/access-tab/developer-api/docs-drawer.tsx b/web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx similarity index 100% rename from web/features/deployments/detail/access-tab/developer-api/docs-drawer.tsx rename to web/features/deployments/detail/api-tokens/docs/docs-drawer.tsx diff --git a/web/features/deployments/detail/access-tab/developer-api/index.tsx b/web/features/deployments/detail/api-tokens/index.tsx similarity index 61% rename from web/features/deployments/detail/access-tab/developer-api/index.tsx rename to web/features/deployments/detail/api-tokens/index.tsx index a223de437d5..9aed81805bb 100644 --- a/web/features/deployments/detail/access-tab/developer-api/index.tsx +++ b/web/features/deployments/detail/api-tokens/index.tsx @@ -1,8 +1,8 @@ 'use client' -import { DeveloperApiSection } from './section' +import { DeveloperApiSection } from './api-token-management/section' -export function DeveloperApiTab() { +export function DeploymentApiTokens() { return (
diff --git a/web/features/deployments/detail/access-tab/state.ts b/web/features/deployments/detail/api-tokens/state.ts similarity index 62% rename from web/features/deployments/detail/access-tab/state.ts rename to web/features/deployments/detail/api-tokens/state.ts index 919d20f2bb7..9eece7967e2 100644 --- a/web/features/deployments/detail/access-tab/state.ts +++ b/web/features/deployments/detail/api-tokens/state.ts @@ -5,19 +5,6 @@ import { atomWithQuery } from 'jotai-tanstack-query' import { consoleQuery } from '@/service/client' import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -export const accessSettingsQueryAtom = atomWithQuery((get) => { - const appInstanceId = get(deploymentRouteAppInstanceIdAtom) - - return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({ - input: appInstanceId - ? { - params: { appInstanceId }, - } - : skipToken, - enabled: Boolean(appInstanceId), - }) -}) - export const developerApiSettingsQueryAtom = atomWithQuery((get) => { const appInstanceId = get(deploymentRouteAppInstanceIdAtom) diff --git a/web/features/deployments/detail/api-tokens/table-styles.ts b/web/features/deployments/detail/api-tokens/table-styles.ts new file mode 100644 index 00000000000..879c8492639 --- /dev/null +++ b/web/features/deployments/detail/api-tokens/table-styles.ts @@ -0,0 +1,6 @@ +export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = { + action: 'w-16 py-1.5 whitespace-nowrap', + environment: 'whitespace-nowrap', + key: '', + name: 'whitespace-nowrap', +} diff --git a/web/features/deployments/detail/components/detail-table-styles.ts b/web/features/deployments/detail/components/detail-table-styles.ts deleted file mode 100644 index e9ca904168b..00000000000 --- a/web/features/deployments/detail/components/detail-table-styles.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { cn } from '@langgenius/dify-ui/cn' - -export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = { - actions: 'w-14 py-1.5 whitespace-nowrap', - currentRelease: '', - environment: 'whitespace-nowrap', - status: 'whitespace-nowrap', -} - -export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = { - action: 'w-14 py-1.5 whitespace-nowrap', - author: 'whitespace-nowrap', - createdAt: 'whitespace-nowrap', - deployedTo: '', - release: 'whitespace-nowrap', - sourceApp: 'whitespace-nowrap', -} - -export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = { - action: 'w-16 py-1.5 whitespace-nowrap', - environment: 'whitespace-nowrap', - key: '', - name: 'whitespace-nowrap', -} - -export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn( - 'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden', - 'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid', - 'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary', - 'disabled:cursor-not-allowed disabled:opacity-50', -) diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index 3e8a013f34e..aef17bfabb0 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -17,9 +17,9 @@ import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skel import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms' import Link from '@/next/link' import { usePathname, useRouter } from '@/next/navigation' -import { DeploymentActionsMenu } from '../components/deployment-actions' -import { TitleTooltip } from '../components/title-tooltip' +import { DeploymentActionsMenu } from '../deployment-actions' import { deploymentRouteAppInstanceIdAtom } from '../route-state' +import { TitleTooltip } from '../shared/components/title-tooltip' import { deploymentDetailAppInstanceQueryAtom } from './state' type TabDef = { @@ -164,7 +164,7 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: { )}
-
-
-
-
-
-
-
-
-
{t(`tabs.${activeTab}.name`)}
- {activeTab === 'api-tokens' && ( -
- -
- )} -
-
{t(`tabs.${activeTab}.description`)}
+
+
+
+
+
+
+
+
+
{t(`tabs.${activeTab}.name`)}
+ {activeTab === 'api-tokens' && ( +
+ +
+ )}
- {(activeTab === 'instances' || activeTab === 'releases') && ( -
- {activeTab === 'instances' - ? - : } -
- )} +
{t(`tabs.${activeTab}.description`)}
+ {(activeTab === 'instances' || activeTab === 'releases') && ( +
+ {activeTab === 'instances' + ? + : } +
+ )}
- - {children}
+ + {children}
- +
) } diff --git a/web/features/deployments/detail/instances/README.md b/web/features/deployments/detail/instances/README.md new file mode 100644 index 00000000000..d358348eca3 --- /dev/null +++ b/web/features/deployments/detail/instances/README.md @@ -0,0 +1,18 @@ +# Deployment Instances + +Instances route for listing environment deployments and running deployment row actions. + +## Internal Modules + +| Module | Why this module uses it | +| ----------------------------------- | ----------------------------------------------------------------------------------- | +| `detail` | Reads detail-owned query state and runtime instance rows before composing sections. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `detail/instances/environment-list` | Renders environment deployment rows. | +| `detail/instances/header-actions` | Renders the new deployment action for empty instances. | + +## External Modules + +| Module | Why this module uses it | +| ------------------------------ | --------------------------------------------------------------- | +| `app/components/base/skeleton` | Reuses skeleton primitives for deployment table loading states. | diff --git a/web/features/deployments/detail/instances/environment-list/README.md b/web/features/deployments/detail/instances/environment-list/README.md new file mode 100644 index 00000000000..cc4154c4202 --- /dev/null +++ b/web/features/deployments/detail/instances/environment-list/README.md @@ -0,0 +1,15 @@ +# Deployment Instances Environment List + +Environment list section for rendering deployment rows and row-level status summaries on the instances route. + +## Internal Modules + +| Module | Why this module uses it | +| ------------------------------ | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `detail/instances` | Uses instances-owned table column class names for deployment rows. | +| `detail/instances/row-actions` | Renders row-level deployment actions for each environment. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx similarity index 93% rename from web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx rename to web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx index 1319a539efa..6bf6bde1fc5 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-environment-list.tsx +++ b/web/features/deployments/detail/instances/environment-list/deployment-environment-list.tsx @@ -2,8 +2,6 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen' import { useTranslation } from 'react-i18next' -import { releaseCommit } from '../../shared/domain/release' -import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' import { DetailTable, DetailTableBody, @@ -13,11 +11,11 @@ import { DetailTableHead, DetailTableHeader, DetailTableRow, -} from '../components/detail-table' -import { - DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES, -} from '../components/detail-table-styles' -import { DeploymentRowActions } from './deployment-row-actions' +} from '../../../shared/components/detail-table' +import { releaseCommit } from '../../../shared/domain/release' +import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' +import { DeploymentRowActions } from '../row-actions/deployment-row-actions' +import { DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles' import { DeploymentStatusSummary } from './deployment-status-summary' function EnvironmentSummary({ environment }: { diff --git a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx similarity index 95% rename from web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx rename to web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx index e5b58e9051e..dfa30fdbe09 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-status-summary.tsx +++ b/web/features/deployments/detail/instances/environment-list/deployment-status-summary.tsx @@ -5,8 +5,8 @@ import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' import { useTranslation } from 'react-i18next' import { isUndeployedDeploymentRow, -} from '../../shared/domain/runtime-status' -import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge' +} from '../../../shared/domain/runtime-status' +import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge' export function DeploymentStatusSummary({ row }: { row: EnvironmentDeployment diff --git a/web/features/deployments/detail/instances/header-actions/README.md b/web/features/deployments/detail/instances/header-actions/README.md new file mode 100644 index 00000000000..d48ed8714a5 --- /dev/null +++ b/web/features/deployments/detail/instances/header-actions/README.md @@ -0,0 +1,15 @@ +# Deployment Instances Header Actions + +Header action for opening the deploy drawer from the instances route. + +## Internal Modules + +| Module | Why this module uses it | +| --------------- | ----------------------------------------------------------------------- | +| `deploy-drawer` | Opens the deploy drawer for the first available undeployed environment. | +| `route-state` | Reads the route app instance identity for deployment actions. | +| `detail` | Reads detail-owned query state and runtime instance rows. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx similarity index 72% rename from web/features/deployments/detail/deploy-tab/new-deployment-button.tsx rename to web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx index b5a06665cdb..30531fde96b 100644 --- a/web/features/deployments/detail/deploy-tab/new-deployment-button.tsx +++ b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx @@ -3,10 +3,12 @@ import { Button } from '@langgenius/dify-ui/button' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' -import { openDeployDrawerAtom } from '../../deploy-drawer/state' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' -import { deploymentEnvironmentDeploymentsQueryAtom } from '../state' +import { openDeployDrawerAtom } from '../../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { + deploymentEnvironmentDeploymentsQueryAtom, + deploymentRuntimeInstanceRowsAtom, +} from '../../state' export function NewDeploymentButton() { const { t } = useTranslation('deployments') @@ -33,7 +35,7 @@ export function NewDeploymentButton() { export function NewDeploymentHeaderAction() { const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom) - const rows = environmentDeploymentsQuery.data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? [] + const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom) if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0) return null diff --git a/web/features/deployments/detail/deploy-tab/index.tsx b/web/features/deployments/detail/instances/index.tsx similarity index 87% rename from web/features/deployments/detail/deploy-tab/index.tsx rename to web/features/deployments/detail/instances/index.tsx index 4f0bdc5cb38..e33ce938d43 100644 --- a/web/features/deployments/detail/deploy-tab/index.tsx +++ b/web/features/deployments/detail/instances/index.tsx @@ -2,8 +2,6 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state' -import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' import { DetailTable, DetailTableBody, @@ -13,13 +11,15 @@ import { DetailTableHead, DetailTableHeader, DetailTableRow, -} from '../components/detail-table' +} from '../../shared/components/detail-table' +import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state' import { - DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES, -} from '../components/detail-table-styles' -import { deploymentEnvironmentDeploymentsQueryAtom } from '../state' -import { DeploymentEnvironmentList } from './deployment-environment-list' -import { NewDeploymentButton } from './new-deployment-button' + deploymentEnvironmentDeploymentsQueryAtom, + deploymentRuntimeInstanceRowsAtom, +} from '../state' +import { DeploymentEnvironmentList } from './environment-list/deployment-environment-list' +import { NewDeploymentButton } from './header-actions/new-deployment-button' +import { DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES } from './table-styles' const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging'] @@ -87,11 +87,10 @@ function DeploymentEnvironmentListSkeleton() { ) } -export function DeployTab() { +export function DeploymentInstances() { const { t } = useTranslation('deployments') const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom) - const environmentDeployments = environmentDeploymentsQuery.data - const rows = environmentDeployments?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? [] + const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom) const isLoading = environmentDeploymentsQuery.isLoading const hasError = environmentDeploymentsQuery.isError diff --git a/web/features/deployments/detail/instances/row-actions/README.md b/web/features/deployments/detail/instances/row-actions/README.md new file mode 100644 index 00000000000..8fad4a09abd --- /dev/null +++ b/web/features/deployments/detail/instances/row-actions/README.md @@ -0,0 +1,15 @@ +# Deployment Instances Row Actions + +Row action menu and dialogs for deploying, redeploying, and undeploying an environment deployment. + +## Internal Modules + +| Module | Why this module uses it | +| --------------- | ------------------------------------------------------------------------------ | +| `deploy-drawer` | Opens the deploy drawer from row-level deploy and redeploy flows. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for deployment mutations. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/deploy-tab/deployment-error-dialog.tsx b/web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx similarity index 100% rename from web/features/deployments/detail/deploy-tab/deployment-error-dialog.tsx rename to web/features/deployments/detail/instances/row-actions/deployment-error-dialog.tsx diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx similarity index 97% rename from web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx rename to web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx index 29dc75222c2..9a2b89fa937 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions-menu.tsx +++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions-menu.tsx @@ -10,7 +10,7 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles' +import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../../../shared/components/detail-table-styles' export function DeploymentActionsDropdown({ currentReleaseId, diff --git a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx similarity index 93% rename from web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx rename to web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx index f5c82f473a7..b6d9b1cbcea 100644 --- a/web/features/deployments/detail/deploy-tab/deployment-row-actions.tsx +++ b/web/features/deployments/detail/instances/row-actions/deployment-row-actions.tsx @@ -7,10 +7,10 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { openDeployDrawerAtom } from '../../deploy-drawer/state' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency' -import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' +import { openDeployDrawerAtom } from '../../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { createDeploymentIdempotencyKey } from '../../../shared/domain/idempotency' +import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' import { DeploymentErrorDialog } from './deployment-error-dialog' import { DeploymentActionsDropdown } from './deployment-row-actions-menu' import { UndeployDeploymentDialog } from './undeploy-deployment-dialog' diff --git a/web/features/deployments/detail/deploy-tab/undeploy-deployment-dialog.tsx b/web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx similarity index 100% rename from web/features/deployments/detail/deploy-tab/undeploy-deployment-dialog.tsx rename to web/features/deployments/detail/instances/row-actions/undeploy-deployment-dialog.tsx diff --git a/web/features/deployments/detail/instances/table-styles.ts b/web/features/deployments/detail/instances/table-styles.ts new file mode 100644 index 00000000000..aabaa9c9039 --- /dev/null +++ b/web/features/deployments/detail/instances/table-styles.ts @@ -0,0 +1,6 @@ +export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = { + actions: 'w-14 py-1.5 whitespace-nowrap', + currentRelease: '', + environment: 'whitespace-nowrap', + status: 'whitespace-nowrap', +} diff --git a/web/features/deployments/detail/overview-tab/card-styles.ts b/web/features/deployments/detail/overview-tab/card-styles.ts deleted file mode 100644 index 07afcf3d0c2..00000000000 --- a/web/features/deployments/detail/overview-tab/card-styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { cn } from '@langgenius/dify-ui/cn' - -export const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' - -export const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn( - OVERVIEW_CARD_CLASS_NAME, - 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg', -) - -export const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' diff --git a/web/features/deployments/detail/overview/README.md b/web/features/deployments/detail/overview/README.md new file mode 100644 index 00000000000..aafa58594e8 --- /dev/null +++ b/web/features/deployments/detail/overview/README.md @@ -0,0 +1,17 @@ +# Deployment Overview + +Overview route for release summary, environment status, and access summaries on a deployment app instance. + +## Internal Modules + +| Module | Why this module uses it | +| ------------------------------------ | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for overview query, links, and actions. | +| `detail/overview/access-summary` | Renders access and API token status summary sections. | +| `detail/overview/environment-status` | Renders environment deployment status tiles. | +| `detail/overview/release-summary` | Renders latest release summary and release creation entry point. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/overview/__tests__/state.spec.ts b/web/features/deployments/detail/overview/__tests__/state.spec.ts new file mode 100644 index 00000000000..30df06f0855 --- /dev/null +++ b/web/features/deployments/detail/overview/__tests__/state.spec.ts @@ -0,0 +1,72 @@ +import type { Getter } from 'jotai' +import { skipToken } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryKey?: readonly unknown[] +} + +vi.mock('jotai-tanstack-query', () => ({ + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + appInstanceService: { + getAppInstanceOverview: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['getAppInstanceOverview', options.input], + }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../state') +} + +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + +describe('deployment overview state', () => { + it('should disable overview query with skipToken until route state is ready', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.deploymentOverviewQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + }) + + it('should build overview query input from route identity', async () => { + const state = await loadState() + const store = createStore() + + setDeploymentRoute(store) + + expect(store.get(state.deploymentOverviewQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + }) +}) diff --git a/web/features/deployments/detail/overview/access-summary/README.md b/web/features/deployments/detail/overview/access-summary/README.md new file mode 100644 index 00000000000..7d431372afd --- /dev/null +++ b/web/features/deployments/detail/overview/access-summary/README.md @@ -0,0 +1,15 @@ +# Deployment Overview Access Summary + +Access summary section for showing webapp and API token status on the overview route. + +## Internal Modules + +| Module | Why this module uses it | +| ----------------- | ------------------------------------------------------------------------------ | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for access summary links. | +| `detail/overview` | Reuses overview card and icon styling shared by overview sections. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/overview-tab/access-status-section.tsx b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx similarity index 92% rename from web/features/deployments/detail/overview-tab/access-status-section.tsx rename to web/features/deployments/detail/overview/access-summary/access-status-section.tsx index 0187315d35d..8f2fbb0bfa9 100644 --- a/web/features/deployments/detail/overview-tab/access-status-section.tsx +++ b/web/features/deployments/detail/overview/access-summary/access-status-section.tsx @@ -7,9 +7,8 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle } from '@/app/components/base/skeleton' import Link from '@/next/link' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge' -import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge' type AccessStatusSectionProps = { accessChannels?: AccessChannels @@ -31,6 +30,12 @@ type AccessStatusItem = { } const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli'] +const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn( + OVERVIEW_CARD_CLASS_NAME, + 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg', +) +const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) { const { t } = useTranslation('deployments') diff --git a/web/features/deployments/detail/overview/environment-status/README.md b/web/features/deployments/detail/overview/environment-status/README.md new file mode 100644 index 00000000000..a00b4814cb6 --- /dev/null +++ b/web/features/deployments/detail/overview/environment-status/README.md @@ -0,0 +1,16 @@ +# Deployment Overview Environment Status + +Environment status section for previewing runtime deployments and opening deployment actions from the overview route. + +## Internal Modules + +| Module | Why this module uses it | +| ----------------- | ------------------------------------------------------------------------------ | +| `deploy-drawer` | Opens the deploy drawer from overview environment actions. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for environment links and actions. | +| `detail/overview` | Reuses overview card and icon styling shared by overview sections. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts similarity index 99% rename from web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts rename to web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts index 466862a5217..c7d9e65a06e 100644 --- a/web/features/deployments/detail/overview-tab/environment-tile-utils.spec.ts +++ b/web/features/deployments/detail/overview/environment-status/__tests__/environment-tile-utils.spec.ts @@ -6,7 +6,7 @@ import { renderDriftTitle, renderStatus, resolveConfig, -} from './environment-tile-utils' +} from '../environment-tile-utils' const t = ((key: string, options?: Record) => { return options ? `${key}:${JSON.stringify(options)}` : key diff --git a/web/features/deployments/detail/overview-tab/environment-strip.tsx b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx similarity index 92% rename from web/features/deployments/detail/overview-tab/environment-strip.tsx rename to web/features/deployments/detail/overview/environment-status/environment-strip.tsx index 39af33bb45b..9267056f8fe 100644 --- a/web/features/deployments/detail/overview-tab/environment-strip.tsx +++ b/web/features/deployments/detail/overview/environment-status/environment-strip.tsx @@ -7,14 +7,14 @@ import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import Link from '@/next/link' -import { DeploymentEmptyState } from '../../components/empty-state' -import { openDeployDrawerAtom } from '../../deploy-drawer/state' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' -import { OVERVIEW_CARD_CLASS_NAME } from './card-styles' +import { openDeployDrawerAtom } from '../../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { DeploymentEmptyState } from '../../../shared/components/empty-state' +import { hasRuntimeInstanceDeployment } from '../../../shared/domain/runtime-status' import { EnvironmentTile } from './environment-tile' const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4 +const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' type EnvironmentStripProps = { rows: EnvironmentDeployment[] diff --git a/web/features/deployments/detail/overview-tab/environment-tile-utils.ts b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts similarity index 98% rename from web/features/deployments/detail/overview-tab/environment-tile-utils.ts rename to web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts index aba4ee23cdd..a6947d09698 100644 --- a/web/features/deployments/detail/overview-tab/environment-tile-utils.ts +++ b/web/features/deployments/detail/overview/environment-status/environment-tile-utils.ts @@ -2,7 +2,7 @@ import type { RuntimeInstanceStatus as RuntimeInstanceStatusValue } from '@dify/ import type { TFunction } from 'i18next' import type { computeDrift } from './overview-drift' import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' -import { isRuntimeDeploymentInProgress } from '../../shared/domain/runtime-status' +import { isRuntimeDeploymentInProgress } from '../../../shared/domain/runtime-status' export type TileKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed' diff --git a/web/features/deployments/detail/overview-tab/environment-tile.tsx b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx similarity index 85% rename from web/features/deployments/detail/overview-tab/environment-tile.tsx rename to web/features/deployments/detail/overview/environment-status/environment-tile.tsx index 49247461b29..ff3ae6da051 100644 --- a/web/features/deployments/detail/overview-tab/environment-tile.tsx +++ b/web/features/deployments/detail/overview/environment-status/environment-tile.tsx @@ -10,15 +10,14 @@ import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import Link from '@/next/link' -import { TitleTooltip } from '../../components/title-tooltip' -import { openDeployDrawerAtom } from '../../deploy-drawer/state' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { releaseCommit } from '../../shared/domain/release' -import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge' +import { openDeployDrawerAtom } from '../../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { TitleTooltip } from '../../../shared/components/title-tooltip' +import { releaseCommit } from '../../../shared/domain/release' +import { DeploymentStatusBadge } from '../../../shared/ui/deployment-status-badge' import { deploymentStatusLabelKey, -} from '../../shared/ui/deployment-status-style' -import { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles' +} from '../../../shared/ui/deployment-status-style' import { renderActionLabel, renderDriftTitle, @@ -27,6 +26,13 @@ import { } from './environment-tile-utils' import { computeDrift, latestReleaseId } from './overview-drift' +const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn( + OVERVIEW_CARD_CLASS_NAME, + 'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg', +) +const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' + type EnvironmentTileProps = { row: EnvironmentDeployment releaseRows: Release[] diff --git a/web/features/deployments/detail/overview-tab/overview-drift.ts b/web/features/deployments/detail/overview/environment-status/overview-drift.ts similarity index 91% rename from web/features/deployments/detail/overview-tab/overview-drift.ts rename to web/features/deployments/detail/overview/environment-status/overview-drift.ts index 862b1fd553c..79ffc500625 100644 --- a/web/features/deployments/detail/overview-tab/overview-drift.ts +++ b/web/features/deployments/detail/overview/environment-status/overview-drift.ts @@ -1,5 +1,5 @@ import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen' -import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' +import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' export type Drift = | { kind: 'undeployed' } diff --git a/web/features/deployments/detail/overview-tab/index.tsx b/web/features/deployments/detail/overview/index.tsx similarity index 90% rename from web/features/deployments/detail/overview-tab/index.tsx rename to web/features/deployments/detail/overview/index.tsx index 9b0d2aae2a8..da486fe10ca 100644 --- a/web/features/deployments/detail/overview-tab/index.tsx +++ b/web/features/deployments/detail/overview/index.tsx @@ -3,13 +3,13 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import Link from '@/next/link' -import { DeploymentStateMessage } from '../../components/empty-state' import { deploymentRouteAppInstanceIdAtom } from '../../route-state' +import { DeploymentStateMessage } from '../../shared/components/empty-state' import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status' -import { deploymentDetailOverviewQueryAtom } from '../state' -import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-status-section' -import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip' -import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero' +import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-summary/access-status-section' +import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-status/environment-strip' +import { ReleaseHero, ReleaseHeroSkeleton } from './release-summary/release-hero' +import { deploymentOverviewQueryAtom } from './state' function OverviewLayout({ children }: { children: React.ReactNode }) { return ( @@ -61,9 +61,9 @@ function OverviewLoadingSkeleton() { ) } -export function OverviewTab() { +export function DeploymentOverview() { const { t } = useTranslation('deployments') - const overviewQuery = useAtomValue(deploymentDetailOverviewQueryAtom) + const overviewQuery = useAtomValue(deploymentOverviewQueryAtom) const overview = overviewQuery.data if (overviewQuery.isLoading) diff --git a/web/features/deployments/detail/overview/release-summary/README.md b/web/features/deployments/detail/overview/release-summary/README.md new file mode 100644 index 00000000000..10ca6d364d1 --- /dev/null +++ b/web/features/deployments/detail/overview/release-summary/README.md @@ -0,0 +1,16 @@ +# Deployment Overview Release Summary + +Release summary section for showing the latest release and release creation entry point on the overview route. + +## Internal Modules + +| Module | Why this module uses it | +| ----------------- | ------------------------------------------------------------------------------ | +| `create-release` | Opens release creation from the release summary card. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for release creation. | +| `detail/overview` | Reuses overview card and icon styling shared by overview sections. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/overview-tab/release-hero.tsx b/web/features/deployments/detail/overview/release-summary/release-hero.tsx similarity index 90% rename from web/features/deployments/detail/overview-tab/release-hero.tsx rename to web/features/deployments/detail/overview/release-summary/release-hero.tsx index c39887d7b90..97751a07876 100644 --- a/web/features/deployments/detail/overview-tab/release-hero.tsx +++ b/web/features/deployments/detail/overview/release-summary/release-hero.tsx @@ -11,12 +11,11 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' import { consoleQuery } from '@/service/client' -import { DeploymentEmptyState } from '../../components/empty-state' -import { TitleTooltip } from '../../components/title-tooltip' -import { CreateReleaseControl } from '../../create-release' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { formatDate, releaseCommit } from '../../shared/domain/release' -import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles' +import { CreateReleaseControl } from '../../../create-release' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { DeploymentEmptyState } from '../../../shared/components/empty-state' +import { TitleTooltip } from '../../../shared/components/title-tooltip' +import { formatDate, releaseCommit } from '../../../shared/domain/release' type ReleaseHeroProps = { latestRelease?: Release @@ -29,6 +28,9 @@ type ReleaseMetaItemProps = { children: ReactNode } +const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4' +const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary' + export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) diff --git a/web/features/deployments/detail/overview/state.ts b/web/features/deployments/detail/overview/state.ts new file mode 100644 index 00000000000..c9b1c9b7bc9 --- /dev/null +++ b/web/features/deployments/detail/overview/state.ts @@ -0,0 +1,19 @@ +'use client' + +import { skipToken } from '@tanstack/react-query' +import { atomWithQuery } from 'jotai-tanstack-query' +import { consoleQuery } from '@/service/client' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' + +export const deploymentOverviewQueryAtom = atomWithQuery((get) => { + const appInstanceId = get(deploymentRouteAppInstanceIdAtom) + + return consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({ + input: appInstanceId + ? { + params: { appInstanceId }, + } + : skipToken, + enabled: Boolean(appInstanceId), + }) +}) diff --git a/web/features/deployments/detail/releases/README.md b/web/features/deployments/detail/releases/README.md new file mode 100644 index 00000000000..72d05992f78 --- /dev/null +++ b/web/features/deployments/detail/releases/README.md @@ -0,0 +1,14 @@ +# Deployment Releases + +Releases route for release history, release actions, deployment summaries, and release DSL export. + +## Internal Modules + +| Module | Why this module uses it | +| --------------------------------- | ---------------------------------------------------------- | +| `route-state` | Reads the route app instance identity for release queries. | +| `detail/releases/release-history` | Renders release history rows and pagination. | + +## External Modules + +None. diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/releases/__tests__/state.spec.ts similarity index 56% rename from web/features/deployments/detail/versions-tab/__tests__/state.spec.ts rename to web/features/deployments/detail/releases/__tests__/state.spec.ts index 2e58fb3bb80..0a2c0dbcdcf 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/releases/__tests__/state.spec.ts @@ -25,22 +25,6 @@ vi.mock('jotai-tanstack-query', () => ({ vi.mock('@/service/client', () => ({ consoleQuery: { enterprise: { - appInstanceService: { - getAppInstance: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['getAppInstance', options.input], - }), - }, - }, - deploymentService: { - listEnvironmentDeployments: { - queryOptions: (options: QueryOptions) => ({ - ...options, - queryKey: ['listEnvironmentDeployments', options.input], - }), - }, - }, releaseService: { listReleaseSummaries: { queryOptions: (options: QueryOptions) => ({ @@ -64,8 +48,8 @@ function setDeploymentRoute(store: ReturnType, appInstanceId }) } -describe('versions tab state', () => { - it('should gate release history and menu queries until route and menu state are ready', async () => { +describe('deployment releases state', () => { + it('should gate release history query until route state is ready', async () => { const state = await loadState() const store = createStore() @@ -73,14 +57,6 @@ describe('versions tab state', () => { enabled: false, input: skipToken, }) - expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({ - enabled: false, - input: skipToken, - }) - expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({ - enabled: false, - input: skipToken, - }) }) it('should build release history input from the current page', async () => { @@ -103,39 +79,6 @@ describe('versions tab state', () => { }) }) - it('should scope deploy menu queries to the open release id', async () => { - const state = await loadState() - const store = createStore() - setDeploymentRoute(store) - - store.set(state.setDeployReleaseMenuOpenAtom, { - releaseId: 'release-1', - open: true, - }) - - expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1') - expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({ - enabled: true, - input: { params: { appInstanceId: 'app-instance-1' } }, - }) - expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({ - enabled: true, - input: { params: { appInstanceId: 'app-instance-1' } }, - }) - - store.set(state.setDeployReleaseMenuOpenAtom, { - releaseId: 'release-2', - open: false, - }) - expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBe('release-1') - - store.set(state.setDeployReleaseMenuOpenAtom, { - releaseId: 'release-1', - open: false, - }) - expect(store.get(state.deployReleaseMenuOpenReleaseIdAtom)).toBeUndefined() - }) - it('should adjust the release page only when deleting the last row on a later page', async () => { const state = await loadState() const store = createStore() diff --git a/web/features/deployments/detail/releases/index.tsx b/web/features/deployments/detail/releases/index.tsx new file mode 100644 index 00000000000..55e44b84eef --- /dev/null +++ b/web/features/deployments/detail/releases/index.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useAtomValue } from 'jotai' +import { ScopeProvider } from 'jotai-scope' +import { deploymentRouteAppInstanceIdAtom } from '../../route-state' +import { ReleaseHistoryTable } from './release-history/release-history-table' +import { releasesLocalAtoms } from './state' + +export function DeploymentReleases() { + const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) + + return ( + +
+ +
+
+ ) +} diff --git a/web/features/deployments/detail/releases/release-actions/README.md b/web/features/deployments/detail/releases/release-actions/README.md new file mode 100644 index 00000000000..1c82270dbed --- /dev/null +++ b/web/features/deployments/detail/releases/release-actions/README.md @@ -0,0 +1,17 @@ +# Deployment Releases Actions + +Release action menu and dialogs for deploying, editing, deleting, and exporting releases. + +## Internal Modules + +| Module | Why this module uses it | +| --------------- | ------------------------------------------------------------------------------ | +| `deploy-drawer` | Opens the deploy drawer from release actions. | +| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. | +| `route-state` | Reads the route app instance identity for action queries and mutations. | + +## External Modules + +| Module | Why this module uses it | +| ---------------- | --------------------------------------- | +| `utils/download` | Downloads exported release DSL content. | diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts similarity index 96% rename from web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts rename to web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts index 0f5073bb102..4f2e394b2e6 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.spec.ts +++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu-utils.spec.ts @@ -6,7 +6,7 @@ import type { import type { TFunction } from 'i18next' import { ReleaseSource, RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen' import { describe, expect, it } from 'vitest' -import { buildDeployMenuSections } from './deploy-release-menu-utils' +import { buildDeployMenuSections } from '../deploy-release-menu-utils' const t = ((key: string, options?: { name?: string }) => { return options?.name ? `${key}:${options.name}` : key diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx similarity index 90% rename from web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx rename to web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx index 8fa353fbbf1..aa22da2ed76 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx @@ -50,9 +50,16 @@ vi.mock('../edit-release-dialog', () => ({ EditReleaseDialog: () => null, })) -vi.mock('../delete-release-dialog', () => ({ - DeleteReleaseDialog: ({ open }: { open: boolean }) => open ?
delete confirm
: null, -})) +vi.mock('../delete-release-dialog', async () => { + const { useAtomValue } = await import('jotai') + const { deleteReleaseDialogOpenAtom } = await import('../state') + + return { + DeleteReleaseDialog: () => useAtomValue(deleteReleaseDialogOpenAtom) + ?
delete confirm
+ : null, + } +}) vi.mock('../release-dsl-export', () => ({ exportReleaseDsl: vi.fn(), diff --git a/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts new file mode 100644 index 00000000000..5424bd62cd7 --- /dev/null +++ b/web/features/deployments/detail/releases/release-actions/__tests__/state.spec.ts @@ -0,0 +1,109 @@ +import type { Getter } from 'jotai' +import { skipToken } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryKey?: readonly unknown[] +} + +vi.mock('jotai-tanstack-query', () => ({ + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + appInstanceService: { + getAppInstance: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['getAppInstance', options.input], + }), + }, + }, + deploymentService: { + listEnvironmentDeployments: { + queryOptions: (options: QueryOptions) => ({ + ...options, + queryKey: ['listEnvironmentDeployments', options.input], + }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../state') +} + +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/releases`, + params: { appInstanceId }, + }) +} + +describe('deployment release actions state', () => { + it('should gate action queries until route and menu state are ready', async () => { + const state = await loadState() + const store = createStore() + + expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({ + enabled: false, + input: skipToken, + }) + + setDeploymentRoute(store) + + expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({ + enabled: false, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + + store.set(state.deployReleaseMenuOpenAtom, true) + + expect(store.get(state.deployReleaseMenuEnvironmentDeploymentsQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + expect(store.get(state.deployReleaseMenuAppInstanceQueryAtom)).toMatchObject({ + enabled: true, + input: { params: { appInstanceId: 'app-instance-1' } }, + }) + }) + + it('should open one secondary dialog at a time and close the menu', async () => { + const state = await loadState() + const store = createStore() + + store.set(state.deployReleaseMenuOpenAtom, true) + store.set(state.openDeleteReleaseDialogAtom) + + expect(store.get(state.deployReleaseMenuOpenAtom)).toBe(false) + expect(store.get(state.editReleaseDialogOpenAtom)).toBe(false) + expect(store.get(state.deleteReleaseDialogOpenAtom)).toBe(true) + + store.set(state.deployReleaseMenuOpenAtom, true) + store.set(state.openEditReleaseDialogAtom) + + expect(store.get(state.deployReleaseMenuOpenAtom)).toBe(false) + expect(store.get(state.editReleaseDialogOpenAtom)).toBe(true) + expect(store.get(state.deleteReleaseDialogOpenAtom)).toBe(false) + }) +}) diff --git a/web/features/deployments/detail/versions-tab/delete-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx similarity index 79% rename from web/features/deployments/detail/versions-tab/delete-release-dialog.tsx rename to web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx index 5e60685f73c..f7d4ad19e0e 100644 --- a/web/features/deployments/detail/versions-tab/delete-release-dialog.tsx +++ b/web/features/deployments/detail/releases/release-actions/delete-release-dialog.tsx @@ -1,6 +1,5 @@ 'use client' -import type { Release } from '@dify/contracts/enterprise/types.gen' import { AlertDialog, AlertDialogActions, @@ -10,22 +9,26 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { useAtom, useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' +import { + deleteReleaseDialogOpenAtom, + releaseActionItemAtom, +} from './state' export function DeleteReleaseDialog({ - open, - release, isDeleting, - onOpenChange, onConfirm, }: { - open: boolean - release: Release isDeleting: boolean - onOpenChange: (open: boolean) => void onConfirm: () => void }) { const { t } = useTranslation('deployments') + const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom) + const [open, setOpen] = useAtom(deleteReleaseDialogOpenAtom) + const release = releaseRows.find(release => release.id === releaseId) + if (!release) + return null return ( { if (isDeleting) return - onOpenChange(nextOpen) + setOpen(nextOpen) }} > diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts similarity index 96% rename from web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts rename to web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts index 9498aab0024..df3b5772812 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu-utils.ts +++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu-utils.ts @@ -4,8 +4,8 @@ import type { Release, } from '@dify/contracts/enterprise/types.gen' import type { TFunction } from 'i18next' -import { releaseDeploymentAction } from '../../shared/domain/release-action' -import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' +import { releaseDeploymentAction } from '../../../shared/domain/release-action' +import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' export type DeployMenuRowState = 'deploy' | 'rollback' | 'current' | 'deploying' diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx similarity index 82% rename from web/features/deployments/detail/versions-tab/deploy-release-menu.tsx rename to web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx index 8f97d27533f..ce97ff261b8 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx @@ -10,15 +10,15 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' import { mutationOptions, useMutation } from '@tanstack/react-query' -import { useAtomValue, useSetAtom } from 'jotai' -import { useState } from 'react' +import { useAtom, useAtomValue, useSetAtom } from 'jotai' +import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' -import { TitleTooltip } from '../../components/title-tooltip' -import { openDeployDrawerAtom } from '../../deploy-drawer/state' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' -import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status' -import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles' +import { openDeployDrawerAtom } from '../../../deploy-drawer/state' +import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../../../shared/components/detail-table-styles' +import { TitleTooltip } from '../../../shared/components/title-tooltip' +import { isUndeployedDeploymentRow } from '../../../shared/domain/runtime-status' import { DeleteReleaseDialog } from './delete-release-dialog' import { buildDeployMenuSections, @@ -27,10 +27,14 @@ import { import { EditReleaseDialog } from './edit-release-dialog' import { exportReleaseDsl } from './release-dsl-export' import { + deleteReleaseDialogOpenAtom, deployReleaseMenuAppInstanceQueryAtom, deployReleaseMenuEnvironmentDeploymentsQueryAtom, - deployReleaseMenuOpenReleaseIdAtom, - setDeployReleaseMenuOpenAtom, + deployReleaseMenuOpenAtom, + openDeleteReleaseDialogAtom, + openEditReleaseDialogAtom, + releaseActionItemAtom, + releaseActionLocalAtoms, } from './state' type ExportReleaseDslInput = { @@ -39,19 +43,17 @@ type ExportReleaseDslInput = { appInstanceName?: string } -export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { - releaseId: string - releaseRows: Release[] +function DeployReleaseMenuContent({ onDeleted }: { onDeleted?: () => void }) { const { t } = useTranslation('deployments') const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom) const openDeployDrawer = useSetAtom(openDeployDrawerAtom) - const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom) - const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom) - const [showEditDialog, setShowEditDialog] = useState(false) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const open = openReleaseMenuId === releaseId + const { releaseId, releaseRows } = useAtomValue(releaseActionItemAtom) + const [open, setOpen] = useAtom(deployReleaseMenuOpenAtom) + const setDeleteReleaseDialogOpen = useSetAtom(deleteReleaseDialogOpenAtom) + const openEditReleaseDialog = useSetAtom(openEditReleaseDialogAtom) + const openDeleteReleaseDialog = useSetAtom(openDeleteReleaseDialogAtom) const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom) const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom) const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions()) @@ -86,10 +88,6 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { : undefined const deleteActionDisabled = isDeletingRelease || isCheckingDeleteUsage || hasDeleteUsageCheckFailed || isReleaseInUse - function handleOpenChange(nextOpen: boolean) { - setDeployReleaseMenuOpen({ releaseId, open: nextOpen }) - } - function handleExportDsl() { if (isExportingDsl) return @@ -98,7 +96,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { { release, releaseId, appInstanceName }, { onSuccess: () => { - handleOpenChange(false) + setOpen(false) }, onError: () => { toast.error(t('versions.exportDslFailed')) @@ -119,7 +117,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { }, { onSuccess: () => { - setShowDeleteConfirm(false) + setDeleteReleaseDialogOpen(false) toast.success(t('versions.deleteSuccess', { name: targetReleaseName })) onDeleted?.() }, @@ -141,7 +139,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { return ( <> - + { - handleOpenChange(false) - setShowEditDialog(true) - }} + onClick={openEditReleaseDialog} > @@ -197,7 +192,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { onClick={() => { if (isDisabled || !appInstanceId) return - handleOpenChange(false) + setOpen(false) openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId }) }} > @@ -223,8 +218,7 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { onClick={() => { if (deleteActionDisabled) return - handleOpenChange(false) - setShowDeleteConfirm(true) + openDeleteReleaseDialog() }} > @@ -235,19 +229,31 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { )} - + ) } + +export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: { + releaseId: string + releaseRows: Release[] + onDeleted?: () => void +}) { + return ( + + + + ) +} diff --git a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx similarity index 59% rename from web/features/deployments/detail/versions-tab/edit-release-dialog.tsx rename to web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx index 28ca0b6ff1d..107fa4e78ea 100644 --- a/web/features/deployments/detail/versions-tab/edit-release-dialog.tsx +++ b/web/features/deployments/detail/releases/release-actions/edit-release-dialog.tsx @@ -1,7 +1,6 @@ 'use client' import type { Release } from '@dify/contracts/enterprise/types.gen' -import type { FormEvent } from 'react' import { Button } from '@langgenius/dify-ui/button' import { Dialog, @@ -10,19 +9,43 @@ import { DialogDescription, DialogTitle, } from '@langgenius/dify-ui/dialog' -import { Input } from '@langgenius/dify-ui/input' +import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { Form } from '@langgenius/dify-ui/form' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' -import { useState } from 'react' +import { useAtom, useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' +import { + editReleaseDialogOpenAtom, + releaseActionItemAtom, +} from './state' type EditReleaseFormValues = { name: string description: string } +function normalizedEditReleaseFormValues(value: EditReleaseFormValues) { + return { + name: value.name.trim(), + description: value.description.trim(), + } +} + +function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, value: EditReleaseFormValues) { + const normalizedValues = normalizedEditReleaseFormValues(value) + + return Boolean( + normalizedValues.name + && ( + normalizedValues.name !== initialValues.name + || normalizedValues.description !== initialValues.description + ), + ) +} + function EditReleaseForm({ release, isSaving, @@ -35,66 +58,47 @@ function EditReleaseForm({ onSubmit: (values: EditReleaseFormValues) => void }) { const { t } = useTranslation('deployments') - const initialName = release.displayName - const initialDescription = release.description - const [name, setName] = useState(initialName) - const [description, setDescription] = useState(initialDescription) - const normalizedName = name.trim() - const normalizedDescription = description.trim() - const nameRequired = !normalizedName - const hasChanges = normalizedName !== initialName || normalizedDescription !== initialDescription - const canSave = Boolean(!nameRequired && hasChanges && !isSaving) + const nameLabel = t('versions.releaseNameLabel') + const initialValues = { + name: release.displayName, + description: release.description, + } - function handleSubmit(event: FormEvent) { - event.preventDefault() - if (!canSave) + function handleSubmit(values: EditReleaseFormValues) { + if (!canSubmitEditReleaseForm(initialValues, values)) return - onSubmit({ - name: normalizedName, - description: normalizedDescription, - }) + onSubmit(normalizedEditReleaseFormValues(values)) } return ( -
-
- - className="flex flex-col gap-4" onFormSubmit={handleSubmit}> + + + {nameLabel} + + setName(event.target.value)} className="h-8" /> - {nameRequired && ( - - )} -
-
-
- - {t('versions.optional')} -
+ {t('versions.releaseNameRequired')} + + + + {t('versions.releaseDescriptionLabel')} + {t('versions.optional')} +