'use client' import type { CreateReleaseResponse, ListReleasesResponse, ListReleaseSummariesResponse, } from '@dify/contracts/enterprise/types.gen' import type { Getter } from 'jotai/vanilla' import type { UnsupportedDslNode } from '../../shared/domain/error' import type { App } from '@/types/app' import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithForm, createFormAtoms } from 'jotai-tanstack-form' import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery, queryClientAtom, } from 'jotai-tanstack-query' import { selectAtom } from 'jotai/utils' import * as z from 'zod' import { consoleQuery } from '@/service/client' import { normalizeAppPagination } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl' import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags' export type ReleaseSourceMode = 'sourceApp' | 'dsl' export type SourceAppPickerValue = Pick & Partial> export type CreateReleaseFormValues = { releaseSourceMode: ReleaseSourceMode sourceApp?: SourceAppPickerValue dslFile?: File releaseName: string releaseDescription: string } const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = { releaseSourceMode: 'sourceApp', sourceApp: undefined, dslFile: undefined, releaseName: '', releaseDescription: '', } export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired' const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 const CREATE_RELEASE_SOURCE_APP_PAGE_SIZE = 20 function deploymentReleaseSourceMode(mode: ReleaseSourceMode): ReleaseSourceMode { return mode === 'dsl' && !isDeploymentDslImportEnabled ? 'sourceApp' : mode } function workflowSourceAppPickerValue( value: unknown, fallbackId: string, ): SourceAppPickerValue | undefined { if (!value || typeof value !== 'object') return undefined const record = value as Record const mode = typeof record.mode === 'string' ? record.mode : undefined if (mode !== AppModeEnum.WORKFLOW) return undefined const id = typeof record.id === 'string' && record.id ? record.id : fallbackId const name = typeof record.name === 'string' && record.name ? record.name : id return { id, name, mode, } } const createReleaseFormSchema = z.object({ releaseSourceMode: z.union([z.literal('sourceApp'), z.literal('dsl')]), sourceApp: z.custom().optional(), dslFile: z.custom().optional(), releaseName: z.string().trim().min(1, RELEASE_NAME_REQUIRED_ERROR), releaseDescription: z.string(), }) type CreateReleaseSubmit = ( value: CreateReleaseFormValues, ) => Promise | CreateReleaseResponse | undefined type CreateReleaseSubmitMeta = { createRelease: CreateReleaseSubmit } const noopCreateRelease: CreateReleaseSubmit = () => undefined // Form state export const createReleaseFormAtom = atomWithForm({ defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES, onSubmitMeta: { createRelease: noopCreateRelease, }, validators: { onSubmit: createReleaseFormSchema, }, onSubmit: ({ value, meta }) => meta.createRelease(value), }) const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom) export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom export const createReleaseSourceModeFieldAtom = createReleaseFormAtoms.fieldAtom('releaseSourceMode') export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp') export const createReleaseDslFileFieldAtom = createReleaseFormAtoms.fieldAtom('dslFile') export const createReleaseNameFieldAtom = createReleaseFormAtoms.fieldAtom('releaseName') export const createReleaseDescriptionFieldAtom = createReleaseFormAtoms.fieldAtom('releaseDescription') // Dialog and source primitives export const createReleaseAppInstanceIdAtom = atom(undefined) export const createReleaseDialogOpenAtom = atom(false) const createReleaseDslFileReadVersionAtom = atom(0) function requiredAppInstanceId(get: Getter) { const appInstanceId = get(createReleaseAppInstanceIdAtom) if (!appInstanceId) throw new Error('Missing create release app instance id.') return appInstanceId } // Query and remote data const latestSourceReleaseQueryAtom = atomWithQuery((get) => { const appInstanceId = get(createReleaseAppInstanceIdAtom) return consoleQuery.enterprise.releaseService.listReleases.queryOptions({ input: appInstanceId ? { params: { appInstanceId }, query: { pageNumber: 1, resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE, }, } : skipToken, enabled: Boolean(appInstanceId && get(createReleaseDialogOpenAtom)), }) }) function latestReleaseSourceAppId(get: Getter) { const latestReleaseQuery = get(latestSourceReleaseQueryAtom) return latestReleaseQuery.data?.releases[0]?.sourceAppId } const defaultSourceAppQueryAtom = atomWithQuery((get) => { const latestSourceAppId = latestReleaseSourceAppId(get) return consoleQuery.apps.byAppId.get.queryOptions({ input: latestSourceAppId ? { params: { app_id: latestSourceAppId }, } : skipToken, enabled: Boolean(get(createReleaseDialogOpenAtom) && latestSourceAppId), }) }) function defaultSourceApp(get: Getter) { const latestSourceAppId = latestReleaseSourceAppId(get) if (!latestSourceAppId) return undefined return workflowSourceAppPickerValue(get(defaultSourceAppQueryAtom).data, latestSourceAppId) } function submittedReleaseName(get: Getter) { return get(createReleaseNameFieldAtom).value.trim() } function cachedReleaseDisplayNames(get: Getter) { const appInstanceId = get(createReleaseAppInstanceIdAtom) if (!appInstanceId) return [] const queryClient = get(queryClientAtom) const releaseSummaryQueries = queryClient.getQueriesData({ queryKey: consoleQuery.enterprise.releaseService.listReleaseSummaries.key({ type: 'query', input: { params: { appInstanceId } }, }), }) const releaseQueries = queryClient.getQueriesData({ queryKey: consoleQuery.enterprise.releaseService.listReleases.key({ type: 'query', input: { params: { appInstanceId } }, }), }) return [ ...releaseSummaryQueries.flatMap(([, data]) => { return data?.releaseSummaries.map((summary) => summary.release.displayName) ?? [] }), ...releaseQueries.flatMap(([, data]) => { return data?.releases.map((release) => release.displayName) ?? [] }), ] } export const createReleaseHasNameConflictAtom = atom((get) => { const releaseName = submittedReleaseName(get) if (!releaseName) return false return cachedReleaseDisplayNames(get).some((displayName) => displayName.trim() === releaseName) }) const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => { const file = get(createReleaseDslFileFieldAtom).value const fileReadVersion = get(createReleaseDslFileReadVersionAtom) return queryOptions({ queryKey: [ 'createReleaseDslFileContent', fileReadVersion, file, file?.name ?? '', file?.size ?? 0, file?.lastModified ?? 0, ], queryFn: async () => (file ? await file.text() : ''), enabled: Boolean(file), retry: false, }) }) // Source derived state function effectiveCreateReleaseSourceMode(get: Getter) { return deploymentReleaseSourceMode(get(createReleaseSourceModeFieldAtom).value) } export const createReleaseSourceModeAtom = atom((get) => { return effectiveCreateReleaseSourceMode(get) }) export const createReleaseSourceAppSearchTextAtom = atom('') export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => { const searchText = get(createReleaseSourceAppSearchTextAtom) return consoleQuery.apps.get.infiniteOptions({ input: (pageParam) => ({ query: { page: Number(pageParam), limit: CREATE_RELEASE_SOURCE_APP_PAGE_SIZE, name: searchText, mode: AppModeEnum.WORKFLOW, }, }), getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), initialPageParam: 1, placeholderData: keepPreviousData, select: (data) => ({ ...data, pages: data.pages.map(normalizeAppPagination), }), enabled: Boolean( get(createReleaseDialogOpenAtom) && effectiveCreateReleaseSourceMode(get) === 'sourceApp' && isDeploymentDslImportEnabled, ), }) }) const createReleaseSourceAppsDataAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.data, ) export const createReleaseSourceAppsErrorAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.error, ) export const createReleaseSourceAppsFetchNextPageAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.fetchNextPage, ) export const createReleaseSourceAppsHasNextPageAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.hasNextPage, ) export const createReleaseSourceAppsIsFetchingAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.isFetching, ) export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.isFetchingNextPage, ) export const createReleaseSourceAppsIsLoadingAtom = selectAtom( createReleaseSourceAppsQueryAtom, (query) => query.isLoading, ) export const createReleaseSourceAppsAtom = atom((get) => { return get(createReleaseSourceAppsDataAtom)?.pages.flatMap((page) => page.data) ?? [] }) export const createReleaseDslContentAtom = atom((get) => { return get(createReleaseDslFileContentQueryAtom).data ?? '' }) export const createReleaseDslReadErrorAtom = atom((get) => { return Boolean( get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError, ) }) export const isReadingCreateReleaseDslAtom = atom((get) => { const file = get(createReleaseDslFileFieldAtom).value const dslFileContentQuery = get(createReleaseDslFileContentQueryAtom) return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching)) }) export const createReleaseHasDslContentAtom = atom((get) => { return Boolean(get(createReleaseDslContentAtom).trim()) }) export const createReleaseIsWorkflowDslContentAtom = atom((get) => { const dslContent = get(createReleaseDslContentAtom) return get(createReleaseHasDslContentAtom) ? isWorkflowDsl(dslContent) : false }) export const createReleaseEncodedDslContentAtom = atom((get) => { const dslContent = get(createReleaseDslContentAtom) return get(createReleaseHasDslContentAtom) && get(createReleaseIsWorkflowDslContentAtom) ? encodeDslContent(dslContent) : '' }) export const createReleaseSelectedSourceAppAtom = atom((get) => { if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp') return undefined const fieldSourceApp = get(createReleaseSourceAppFieldAtom).value const fallbackSourceApp = defaultSourceApp(get) if (!isDeploymentDslImportEnabled) return fallbackSourceApp return fieldSourceApp ?? fallbackSourceApp }) function selectedSourceAppId(get: Getter) { return effectiveCreateReleaseSourceMode(get) === 'sourceApp' ? get(createReleaseSelectedSourceAppAtom)?.id : undefined } function hasUnsupportedDslMode(get: Getter) { if (effectiveCreateReleaseSourceMode(get) !== 'dsl') return false return ( get(createReleaseHasDslContentAtom) && !get(isReadingCreateReleaseDslAtom) && !get(createReleaseDslReadErrorAtom) && !get(createReleaseIsWorkflowDslContentAtom) ) } export const createReleaseHasUnsupportedDslModeAtom = atom((get) => { return hasUnsupportedDslMode(get) }) function canCheckReleaseSourceContent(get: Getter) { if (effectiveCreateReleaseSourceMode(get) === 'sourceApp') return Boolean(selectedSourceAppId(get)) if (!isDeploymentDslImportEnabled) return false return Boolean( get(createReleaseHasDslContentAtom) && !get(isReadingCreateReleaseDslAtom) && !get(createReleaseDslReadErrorAtom) && !hasUnsupportedDslMode(get), ) } function canCheckReleaseContent(get: Getter) { return Boolean( get(createReleaseAppInstanceIdAtom) && get(createReleaseDialogOpenAtom) && canCheckReleaseSourceContent(get), ) } // Release content check const precheckReleaseQueryAtom = atomWithQuery((get) => { const appInstanceId = get(createReleaseAppInstanceIdAtom) const releaseSourceMode = effectiveCreateReleaseSourceMode(get) const sourceAppId = selectedSourceAppId(get) const canCheck = canCheckReleaseContent(get) return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({ input: canCheck && appInstanceId ? releaseSourceMode === 'dsl' ? { body: { appInstanceId, dsl: get(createReleaseEncodedDslContentAtom), }, } : sourceAppId ? { body: { appInstanceId, sourceAppId, }, } : skipToken : skipToken, enabled: canCheck, retry: false, }) }) export const isCheckingCreateReleaseContentAtom = atom((get) => { const canCheck = canCheckReleaseContent(get) const precheckReleaseQuery = get(precheckReleaseQueryAtom) return canCheck && (precheckReleaseQuery.isLoading || precheckReleaseQuery.isFetching) }) export const createReleaseMatchedReleaseAtom = atom((get) => { return canCheckReleaseContent(get) ? get(precheckReleaseQueryAtom).data?.matchedRelease : undefined }) export const createReleaseContentCheckFailedAtom = atom((get) => { return canCheckReleaseContent(get) && get(precheckReleaseQueryAtom).isError }) export const createReleaseUnsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => { return canCheckReleaseContent(get) ? (get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []) : [] }) export const createReleaseContentReadyAtom = atom((get) => { const canCheck = canCheckReleaseContent(get) const precheckReleaseQuery = get(precheckReleaseQueryAtom) return ( canCheck && precheckReleaseQuery.isSuccess && !get(isCheckingCreateReleaseContentAtom) && !get(createReleaseContentCheckFailedAtom) && Boolean(precheckReleaseQuery.data?.canCreate) && get(createReleaseUnsupportedDslNodesAtom).length === 0 ) }) // Actions const resetCreateReleaseDslFileAtom = atom(null, (get, set) => { set(createReleaseDslFileFieldAtom, undefined) 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) => { if (get(createReleaseFormIsSubmittingAtom)) return set(closeCreateReleaseDialogAtom) }) export const selectCreateReleaseSourceModeAtom = atom( null, (_get, set, releaseSourceMode: ReleaseSourceMode) => { const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode) set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode) if (effectiveReleaseSourceMode === 'sourceApp') { set(resetCreateReleaseDslFileAtom) return } set(createReleaseSourceAppFieldAtom, undefined) }, ) export const updateCreateReleaseSourceAppAtom = atom( null, (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => { set(createReleaseSourceAppFieldAtom, sourceApp) }, ) export const updateCreateReleaseDslFileAtom = atom( null, (get, set, dslFile: CreateReleaseFormValues['dslFile']) => { set(createReleaseDslFileFieldAtom, dslFile) set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) }, ) // Submission const createReleaseMutationAtom = atomWithMutation(() => consoleQuery.enterprise.releaseService.createRelease.mutationOptions(), ) export const isCreatingReleaseAtom = atom((get) => { return get(createReleaseMutationAtom).isPending }) export class CreateReleaseSubmissionBlockedError extends Error { reason: 'unsupportedDslMode' constructor(reason: 'unsupportedDslMode') { super(reason) this.reason = reason this.name = 'CreateReleaseSubmissionBlockedError' } } const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateReleaseFormValues) => { const releaseSourceMode = effectiveCreateReleaseSourceMode(get) const sourceAppId = selectedSourceAppId(get) const submittedReleaseName = value.releaseName.trim() if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName) return undefined if (get(createReleaseHasNameConflictAtom)) return undefined if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom)) return undefined const appInstanceId = requiredAppInstanceId(get) const commonCreateReleaseRequest = { appInstanceId, displayName: submittedReleaseName, description: value.releaseDescription.trim() || undefined, createAppInstance: false, } if (releaseSourceMode === 'dsl') { if (!get(createReleaseIsWorkflowDslContentAtom)) throw new CreateReleaseSubmissionBlockedError('unsupportedDslMode') return await get(createReleaseMutationAtom).mutateAsync({ body: { ...commonCreateReleaseRequest, dsl: get(createReleaseEncodedDslContentAtom), }, }) } if (!sourceAppId) return undefined return await get(createReleaseMutationAtom).mutateAsync({ body: { ...commonCreateReleaseRequest, sourceAppId, }, }) }) export const submitCreateReleaseFormAtom = atom(null, (get, set) => { const form = get(createReleaseFormAtom) let submitResponse: CreateReleaseResponse | undefined return form.api .handleSubmit({ createRelease: async (value) => { const response = await set(createReleaseSubmissionAtom, value) submitResponse = response return response }, } satisfies CreateReleaseSubmitMeta) .then(() => submitResponse) }) // Scoped atoms export const createReleaseLocalAtoms = [ createReleaseDialogOpenAtom, createReleaseDslFileReadVersionAtom, createReleaseSourceAppSearchTextAtom, ] as const