diff --git a/web/features/deployments/create-guide/state/__tests__/index.spec.ts b/web/features/deployments/create-guide/state/__tests__/index.spec.ts new file mode 100644 index 00000000000..910a7b84bb2 --- /dev/null +++ b/web/features/deployments/create-guide/state/__tests__/index.spec.ts @@ -0,0 +1,73 @@ +import type { Getter } from 'jotai' +import { atom, createStore } from 'jotai' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('jotai-tanstack-query', () => ({ + atomWithInfiniteQuery: (createOptions: (get: Getter) => Record) => atom((get) => { + const options = createOptions(get) + + return { + ...options, + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + } + }), + atomWithMutation: () => atom(() => ({ + isPending: false, + mutateAsync: vi.fn(), + })), + atomWithQuery: (createOptions: (get: Getter) => Record) => atom(get => ({ + ...createOptions(get), + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + })), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + list: { + infiniteOptions: (options: Record) => ({ + ...options, + queryKey: ['apps', 'list'], + }), + }, + }, + }, +})) + +async function loadState() { + return await import('../index') +} + +describe('create deployment guide state', () => { + it('should keep the guide on source app mode when DSL import is disabled', async () => { + const state = await loadState() + const store = createStore() + + store.set(state.selectMethodAtom, 'importDsl') + + expect(store.get(state.methodAtom)).toBe('bindApp') + expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + }) + + it('should keep source app loading enabled if stale state points to DSL import', async () => { + const state = await loadState() + const store = createStore() + + store.set(state.methodAtom, 'importDsl') + + const sourceAppsQuery = store.get(state.sourceAppsQueryAtom) as unknown as { enabled?: boolean } + + expect(store.get(state.effectiveMethodAtom)).toBe('bindApp') + expect(sourceAppsQuery.enabled).toBe(true) + }) +}) diff --git a/web/features/deployments/create-guide/state/index.ts b/web/features/deployments/create-guide/state/index.ts index 06dab290172..8108780bff8 100644 --- a/web/features/deployments/create-guide/state/index.ts +++ b/web/features/deployments/create-guide/state/index.ts @@ -27,6 +27,7 @@ import { 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, @@ -41,6 +42,12 @@ 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 @@ -124,6 +131,7 @@ function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | // 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('') @@ -145,7 +153,7 @@ export const dslDefaultAppNameAtom = atom((get) => { export const dslUnsupportedModeAtom = atom((get) => { const dslContent = get(dslContentAtom) - return get(methodAtom) === 'importDsl' + return get(effectiveMethodAtom) === 'importDsl' && Boolean(dslContent.trim()) && !get(isReadingDslAtom) && !get(dslReadErrorAtom) @@ -199,7 +207,7 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => { initialPageParam: 1, placeholderData: keepPreviousData, }), - enabled: get(methodAtom) === 'bindApp', + enabled: get(effectiveMethodAtom) === 'bindApp', } }) @@ -218,7 +226,7 @@ export const effectiveSelectedAppAtom = atom((get) => { }) function sourceReady(get: Getter) { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) return method === 'importDsl' ? get(importDslReadyAtom) @@ -269,7 +277,7 @@ export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => { }) const precheckReleaseQueryAtom = atomWithQuery((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const dslContent = get(dslContentAtom) const enabled = sourceReady(get) @@ -310,7 +318,7 @@ function precheckReleaseReady(get: Getter) { } export const deploymentOptionsQueryAtom = atomWithQuery((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const dslContent = get(dslContentAtom) const enabled = precheckReleaseReady(get) @@ -378,7 +386,7 @@ const deploymentOptionsContentCheckedAtom = atom((get) => { }) export const sourceCanGoNextAtom = atom((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) const importDslReady = method === 'importDsl' && get(importDslReadyAtom) const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) @@ -416,7 +424,7 @@ export const continueFromSourceAtom = atom(null, (get, set, { if (!get(sourceCanGoNextAtom)) return - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const effectiveSelectedApp = get(effectiveSelectedAppAtom) if (method === 'bindApp' && effectiveSelectedApp) set(selectSourceAppAtom, effectiveSelectedApp) @@ -606,7 +614,7 @@ const requiredBindingsReadyAtom = atom((get) => { }) export const deploymentTargetEnvVarSlotsAtom = atom((get) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const deploymentOptionsQuery = get(deploymentOptionsQueryAtom) const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined const dslContent = get(dslContentAtom) @@ -702,7 +710,7 @@ export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarVal // Workflow actions export const selectMethodAtom = atom(null, (_get, set, method: GuideMethod) => { - set(methodAtom, method) + set(methodAtom, deploymentGuideMethod(method)) set(selectedEnvironmentIdAtom, '') set(manualBindingSelectionsAtom, {}) set(envVarValuesAtom, {}) @@ -738,7 +746,7 @@ export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, { }: { deployToEnvironment: boolean }) => { - const method = get(methodAtom) + const method = get(effectiveMethodAtom) const dslContent = get(dslContentAtom) const submittedInstanceName = get(instanceNameAtom).trim() const submittedReleaseName = get(releaseNameAtom).trim() 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 new file mode 100644 index 00000000000..3a99e1418a7 --- /dev/null +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { SourceStepContent } from '../source-step' + +vi.mock('@/features/deployments/create-guide/state', async () => { + const { atom } = await import('jotai') + const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp') + 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({ + data: { pages: [{ data: [] }] }, + hasNextPage: false, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + isPlaceholderData: false, + fetchNextPage: vi.fn(), + }), + sourceCanGoNextAtom: atom(false), + sourceSearchTextAtom: atom(''), + unsupportedDslNodesAtom: atom([]), + } +}) + +describe('SourceStepContent', () => { + it('should hide the import DSL option when deployment DSL import is disabled', () => { + render() + + expect(screen.getByText(/createGuide\.methods\.bindApp\.title/)).toBeInTheDocument() + expect(screen.queryByText(/createGuide\.methods\.importDsl\.title/)).not.toBeInTheDocument() + expect(screen.queryByText(/createGuide\.methods\.importDsl\.description/)).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ })).toBeInTheDocument() + }) +}) diff --git a/web/features/deployments/create-guide/ui/release-step.tsx b/web/features/deployments/create-guide/ui/release-step.tsx index 192351003ab..19494772935 100644 --- a/web/features/deployments/create-guide/ui/release-step.tsx +++ b/web/features/deployments/create-guide/ui/release-step.tsx @@ -7,10 +7,10 @@ import { useTranslation } from 'react-i18next' import { continueFromReleaseAtom, dslDefaultAppNameAtom, + effectiveMethodAtom, hasInstanceNameConflictAtom, instanceDescriptionAtom, instanceNameAtom, - methodAtom, releaseCanGoNextAtom, releaseDescriptionAtom, releaseNameAtom, @@ -74,7 +74,7 @@ function InstanceNameField() { const { t } = useTranslation('deployments') const instanceName = useAtomValue(instanceNameAtom) const setInstanceName = useSetAtom(setInstanceNameAtom) - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const selectedApp = useAtomValue(selectedAppAtom) const dslDefaultAppName = useAtomValue(dslDefaultAppNameAtom) const instanceNamePlaceholder = method === 'importDsl' diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index 832219f1b18..d75b9965773 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -19,9 +19,9 @@ import { dslFileAtom, dslReadErrorAtom, dslUnsupportedModeAtom, + effectiveMethodAtom, effectiveSelectedAppAtom, isReadingDslAtom, - methodAtom, selectDslFileAtom, selectMethodAtom, selectSourceAppAtom, @@ -31,12 +31,13 @@ import { sourceSearchTextAtom, unsupportedDslNodesAtom, } from '@/features/deployments/create-guide/state' +import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags' import { StepShell } from './layout' const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app'] export function SourceStepContent() { - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom) return ( @@ -55,7 +56,7 @@ export function SourceStepContent() { function SourceMethodSection() { const { t } = useTranslation('deployments') - const method = useAtomValue(methodAtom) + const method = useAtomValue(effectiveMethodAtom) const selectMethod = useSetAtom(selectMethodAtom) return ( @@ -76,12 +77,14 @@ function SourceMethodSection() { title={t('createGuide.methods.bindApp.title')} description={t('createGuide.methods.bindApp.description')} /> - + {isDeploymentDslImportEnabled && ( + + )} ) 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 new file mode 100644 index 00000000000..5876a3c5d4f --- /dev/null +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -0,0 +1,34 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SourceAppPicker } from '../source-app-picker' + +function renderSourceAppPicker(disabled: boolean) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + undefined} + ariaLabel="Source app" + disabled={disabled} + /> + , + ) +} + +describe('SourceAppPicker', () => { + it('should disable the switch control when disabled', () => { + renderSourceAppPicker(true) + + expect(screen.getByText('Workflow 1')).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Source app' })).toBeDisabled() + }) +}) diff --git a/web/features/deployments/create-release/ui/__tests__/use-release-content-check.spec.ts b/web/features/deployments/create-release/ui/__tests__/use-release-content-check.spec.ts new file mode 100644 index 00000000000..e9ca69fc366 --- /dev/null +++ b/web/features/deployments/create-release/ui/__tests__/use-release-content-check.spec.ts @@ -0,0 +1,36 @@ +import type { CreateReleaseSourceSelection } from '../use-release-content-check' +import { describe, expect, it } from 'vitest' +import { canCheckReleaseSourceContent } from '../use-release-content-check' + +function releaseSource(overrides: Partial = {}): CreateReleaseSourceSelection { + return { + dslContent: '', + dslReadError: false, + encodedDslContent: '', + hasDslContent: false, + hasUnsupportedDslMode: false, + isReadingDsl: false, + isWorkflowDslContent: false, + releaseSourceMode: 'sourceApp', + selectedSourceAppId: undefined, + ...overrides, + } +} + +describe('canCheckReleaseSourceContent', () => { + it('should allow source app releases when a source app is selected', () => { + expect(canCheckReleaseSourceContent(releaseSource({ + selectedSourceAppId: 'app-1', + }))).toBe(true) + }) + + it('should block DSL release content checks when deployment DSL import is disabled', () => { + expect(canCheckReleaseSourceContent(releaseSource({ + dslContent: 'app:\n mode: workflow', + encodedDslContent: 'encoded-dsl', + hasDslContent: true, + isWorkflowDslContent: true, + releaseSourceMode: 'dsl', + }))).toBe(false) + }) +}) diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx index 501600e73ac..f62d218cf99 100644 --- a/web/features/deployments/create-release/ui/dialog.tsx +++ b/web/features/deployments/create-release/ui/dialog.tsx @@ -8,6 +8,7 @@ import { ScopeProvider } from 'jotai-scope' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' +import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags' import { closeCreateReleaseDialogAtom, createReleaseDialogOpenAtom, @@ -87,13 +88,19 @@ function CreateReleaseDefaultSourceApp({ formValues }: { const defaultSourceApp = latestSourceAppId ? workflowSourceAppPickerValue(defaultSourceAppQuery.data, latestSourceAppId) : undefined + const sourceAppLocked = !isDeploymentDslImportEnabled + const releaseSourceMode = formValues.releaseSourceMode === 'dsl' && !isDeploymentDslImportEnabled + ? 'sourceApp' + : formValues.releaseSourceMode useEffect(() => { - if (!isDialogOpen || formValues.releaseSourceMode !== 'sourceApp' || formValues.sourceApp || !defaultSourceApp) + if (!isDialogOpen || releaseSourceMode !== 'sourceApp' || !defaultSourceApp) + return + if (formValues.sourceApp && (!sourceAppLocked || formValues.sourceApp.id === defaultSourceApp.id)) return form.setFieldValue('sourceApp', defaultSourceApp) - }, [defaultSourceApp, form, formValues.releaseSourceMode, formValues.sourceApp, isDialogOpen]) + }, [defaultSourceApp, form, formValues.sourceApp, isDialogOpen, releaseSourceMode, sourceAppLocked]) return null } 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 93b99e7fac2..1d37fe749e7 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -31,16 +31,20 @@ function sourceAppSearchText(app: App) { return `${app.name} ${app.id}`.toLowerCase() } -function SourceAppTrigger({ open, app }: { +function SourceAppTrigger({ open, app, disabled }: { open: boolean app?: SourceAppPickerValue + disabled: boolean }) { const { t } = useTranslation('deployments') return (