From f380bbaa10a625c7692e37cf0c83f8eb930911c2 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 23 Jun 2026 10:17:54 +0800 Subject: [PATCH] refactor(web): consolidate create release state (#37765) --- .../skills/how-to-write-component/SKILL.md | 9 +- .../jotai-tanstack-form/src/index.spec.ts | 182 +++++++ packages/jotai-tanstack-form/src/index.ts | 42 +- .../deployments/create-release/index.tsx | 53 +- .../state/__tests__/index.spec.ts | 493 ++++++++++++++---- .../deployments/create-release/state/index.ts | 493 ++++++++++++++---- .../ui/__tests__/source-app-picker.spec.tsx | 3 +- .../use-release-content-check.spec.ts | 36 -- .../deployments/create-release/ui/actions.tsx | 41 +- .../create-release/ui/content-feedback.tsx | 34 +- .../deployments/create-release/ui/dialog.tsx | 186 ++----- .../create-release/ui/metadata-fields.tsx | 18 +- .../create-release/ui/source-app-mode.ts | 11 - .../ui/source-app-picker-value.ts | 23 - .../create-release/ui/source-app-picker.tsx | 31 +- .../create-release/ui/source-section.tsx | 37 +- .../ui/use-create-release-submission.ts | 109 ---- .../ui/use-release-content-check.ts | 129 ----- web/i18n/ar-TN/deployments.json | 1 + web/i18n/de-DE/deployments.json | 1 + web/i18n/en-US/deployments.json | 1 + web/i18n/es-ES/deployments.json | 1 + web/i18n/fa-IR/deployments.json | 1 + web/i18n/fr-FR/deployments.json | 1 + web/i18n/hi-IN/deployments.json | 1 + web/i18n/id-ID/deployments.json | 1 + web/i18n/it-IT/deployments.json | 1 + web/i18n/ja-JP/deployments.json | 1 + web/i18n/ko-KR/deployments.json | 1 + web/i18n/nl-NL/deployments.json | 1 + web/i18n/pl-PL/deployments.json | 1 + web/i18n/pt-BR/deployments.json | 1 + web/i18n/ro-RO/deployments.json | 1 + web/i18n/ru-RU/deployments.json | 1 + web/i18n/sl-SI/deployments.json | 1 + web/i18n/th-TH/deployments.json | 1 + web/i18n/tr-TR/deployments.json | 1 + web/i18n/uk-UA/deployments.json | 1 + web/i18n/vi-VN/deployments.json | 1 + web/i18n/zh-Hans/deployments.json | 1 + web/i18n/zh-Hant/deployments.json | 1 + 41 files changed, 1213 insertions(+), 740 deletions(-) delete mode 100644 web/features/deployments/create-release/ui/__tests__/use-release-content-check.spec.ts delete mode 100644 web/features/deployments/create-release/ui/source-app-mode.ts delete mode 100644 web/features/deployments/create-release/ui/source-app-picker-value.ts delete mode 100644 web/features/deployments/create-release/ui/use-create-release-submission.ts delete mode 100644 web/features/deployments/create-release/ui/use-release-content-check.ts diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 738ec9de95a..f7e6e595092 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -36,6 +36,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. - Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. - When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. +- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state. - Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. - Prefer uncontrolled DOM state and CSS variables before adding controlled props. @@ -45,8 +46,10 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. - Derived atom names read as business facts. Write atom names read as user or workflow commands. - UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. -- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. -- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow. +- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. +- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them. +- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need. +- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks. - `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. - Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient. @@ -108,7 +111,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin - Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. - Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. - Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. -- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface. +- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface. ## You Might Not Need An Effect diff --git a/packages/jotai-tanstack-form/src/index.spec.ts b/packages/jotai-tanstack-form/src/index.spec.ts index 6b1d94ae4de..2af107fa324 100644 --- a/packages/jotai-tanstack-form/src/index.spec.ts +++ b/packages/jotai-tanstack-form/src/index.spec.ts @@ -24,6 +24,68 @@ function createTestFormAtom(onSubmit = vi.fn()) { }) } +function createSubmitValidatedFormAtom(onSubmit = vi.fn()) { + const defaultValues: TestFormValues = { + name: '', + count: 0, + } + + return atomWithForm({ + defaultValues, + validators: { + onSubmit: ({ value }) => { + if (value.name.trim()) + return undefined + + return { + fields: { + name: 'required', + }, + } + }, + }, + onSubmit: ({ value }) => { + onSubmit(value) + }, + }) +} + +function createChangeAndSubmitValidatedFormAtom(onSubmit = vi.fn()) { + const defaultValues: TestFormValues = { + name: '', + count: 0, + } + + return atomWithForm({ + defaultValues, + validators: { + onChange: ({ value }) => { + if (value.name !== 'blocked') + return undefined + + return { + fields: { + name: 'blocked', + }, + } + }, + onSubmit: ({ value }) => { + if (value.name.trim()) + return undefined + + return { + fields: { + name: 'required', + }, + } + }, + }, + onSubmit: ({ value }) => { + onSubmit(value) + }, + }) +} + describe('jotai-tanstack-form', () => { it('syncs a TanStack form store into Jotai atoms', () => { const formAtom = createTestFormAtom() @@ -88,6 +150,126 @@ describe('jotai-tanstack-form', () => { unsubscribe() }) + it('clears stale submit errors when a field atom updates the field value', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + + expect(store.get(nameFieldAtom).meta?.errors).toEqual(['required']) + expect(onSubmit).not.toHaveBeenCalled() + + store.set(nameFieldAtom, 'Ada') + await store.set(formAtoms.submitAtom) + + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Ada', + count: 0, + }) + + unsubscribe() + }) + + it('keeps stale submit errors when a field atom update has change errors', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createChangeAndSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'blocked') + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onChange: 'blocked', + onSubmit: 'required', + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('clears stale submit errors when a field atom update only has existing blur errors', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.get(formAtoms.formAtom).api.setFieldMeta('name', prev => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onBlur: 'blurred', + }, + errorSourceMap: { + ...prev.errorSourceMap, + onBlur: 'field', + }, + })) + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onBlur: 'blurred', + onSubmit: 'required', + }) + + store.set(nameFieldAtom, 'ready') + + expect(store.get(nameFieldAtom).meta?.errorMap).toMatchObject({ + onBlur: 'blurred', + onSubmit: undefined, + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('keeps stale submit errors when dontUpdateMeta keeps a field untouched', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'Ada', { dontUpdateMeta: true }) + + expect(store.get(nameFieldAtom).meta).toMatchObject({ + isTouched: false, + errorMap: { + onSubmit: 'required', + }, + }) + expect(onSubmit).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('clears stale submit errors with dontUpdateMeta when the field is already touched', async () => { + const onSubmit = vi.fn() + const formAtoms = createFormAtoms(createSubmitValidatedFormAtom(onSubmit)) + const nameFieldAtom = formAtoms.fieldAtom('name') + const store = createStore() + const unsubscribe = store.sub(formAtoms.stateAtom, () => undefined) + + store.set(nameFieldAtom, '') + await store.set(formAtoms.submitAtom) + store.set(nameFieldAtom, 'Ada', { dontUpdateMeta: true }) + + expect(store.get(nameFieldAtom).meta).toMatchObject({ + isTouched: true, + errorMap: { + onSubmit: undefined, + }, + }) + + unsubscribe() + }) + it('creates and mounts form instances from atom lifecycle', () => { const cleanup = vi.fn() const formAtom = createTestFormAtom() diff --git a/packages/jotai-tanstack-form/src/index.ts b/packages/jotai-tanstack-form/src/index.ts index e0717b75c60..4d5ea4bf325 100644 --- a/packages/jotai-tanstack-form/src/index.ts +++ b/packages/jotai-tanstack-form/src/index.ts @@ -106,6 +106,44 @@ function createFormInstance( } } +function setFormFieldValue< + TValues, + TSubmitMeta, + TField extends DeepKeys, +>( + form: FormAtomInstance, + name: TField, + value: Updater>, + options?: UpdateMetaOptions, +) { + const shouldValidate = !options?.dontValidate + && !(options?.dontUpdateMeta && !form.api.getFieldMeta(name)?.isTouched) + + form.api.setFieldValue(name, value, shouldValidate ? options : { ...(options ?? {}), dontValidate: true }) + + if (!shouldValidate) + return + + const fieldMeta = form.api.getFieldMeta(name) + if (!fieldMeta?.errorMap.onSubmit) + return + + if (fieldMeta.errorMap.onChange || fieldMeta.errorMap.onDynamic) + return + + form.api.setFieldMeta(name, prev => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onSubmit: undefined, + }, + errorSourceMap: { + ...prev.errorSourceMap, + onSubmit: undefined, + }, + })) +} + export function atomWithForm( options: FormOptionsInput, ): FormAtom { @@ -129,7 +167,7 @@ export function createFormAtoms( }) const setFieldAtom = atom], void>(null, (get, _set, update) => { - get(formAtom).api.setFieldValue(update.name, update.value, update.options) + setFormFieldValue(get(formAtom), update.name, update.value, update.options) }) function fieldAtom>( @@ -151,7 +189,7 @@ export function createFormAtoms( } }, (get, _set, value, options) => { - get(formAtom).api.setFieldValue(name, value, options) + setFormFieldValue(get(formAtom), name, value, options) }, ) } diff --git a/web/features/deployments/create-release/index.tsx b/web/features/deployments/create-release/index.tsx index 3bfdde51bcd..504735b8681 100644 --- a/web/features/deployments/create-release/index.tsx +++ b/web/features/deployments/create-release/index.tsx @@ -2,17 +2,21 @@ import type { ButtonProps } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button' -import { useSetAtom } from 'jotai' +import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' import { useTranslation } from 'react-i18next' import { - createReleaseConfigAtom, + createReleaseAppInstanceIdAtom, + createReleaseDialogOpenAtom, createReleaseLocalAtoms, + isCreatingReleaseAtom, openCreateReleaseDialogAtom, + requestCloseCreateReleaseDialogAtom, } from './state' -import { CreateReleaseDialog } from './ui/dialog' +import { CreateReleaseDialogContent } from './ui/dialog' -function CreateReleaseTrigger({ +function CreateReleaseScopedControl({ variant, size, label, @@ -24,17 +28,39 @@ function CreateReleaseTrigger({ className?: string }) { const { t } = useTranslation('deployments') + const open = useAtomValue(createReleaseDialogOpenAtom) + const isCreatingRelease = useAtomValue(isCreatingReleaseAtom) const openDialog = useSetAtom(openCreateReleaseDialogAtom) + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) + + function handleDialogOpenChange(nextOpen: boolean) { + if (nextOpen) { + openDialog() + return + } + + if (!isCreatingRelease) + requestCloseDialog() + } return ( - + + )} + > + {label ?? t('versions.createRelease')} + + {open && } + ) } @@ -55,18 +81,17 @@ export function CreateReleaseControl({ - - ) } 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 d714403aad0..dfb1bb12264 100644 --- a/web/features/deployments/create-release/state/__tests__/index.spec.ts +++ b/web/features/deployments/create-release/state/__tests__/index.spec.ts @@ -1,30 +1,136 @@ +import type { Getter } from 'jotai' import type { CreateReleaseFormValues } from '../index' -import { createStore } from 'jotai' -import { describe, expect, it, vi } from 'vitest' -import { - closeCreateReleaseDialogAtom, - createReleaseDescriptionFieldAtom, - createReleaseDialogOpenAtom, - createReleaseDslFileFieldAtom, - createReleaseDslStateAtom, - createReleaseFormValuesAtom, - createReleaseNameFieldAtom, - createReleaseSourceAppFieldAtom, - createReleaseSourceModeFieldAtom, - createReleaseSubmitUnsupportedDslNodesAtom, - openCreateReleaseDialogAtom, - RELEASE_NAME_REQUIRED_ERROR, - selectCreateReleaseSourceModeAtom, - submitCreateReleaseFormAtom, - updateCreateReleaseDslFileAtom, - updateCreateReleaseSourceAppAtom, -} from '../index' +import { QueryClient } from '@tanstack/react-query' +import { atom, createStore } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { consoleQuery } from '@/service/client' -function mountedStore() { - const store = createStore() - const unsubscribe = store.sub(createReleaseFormValuesAtom, () => undefined) +type QueryResult = { + data?: unknown + isError?: boolean + isFetching?: boolean + isLoading?: boolean + isSuccess?: boolean +} + +type QueryOptions = { + enabled?: boolean + input?: unknown + queryFn?: () => unknown + queryKey?: readonly unknown[] + retry?: boolean +} + +type MutationResult = { + isPending: boolean + mutateAsync: ReturnType +} + +const mockQueryResults = vi.hoisted(() => ({ + current: new Map(), +})) + +const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({ + current: { + isPending: false, + mutateAsync: vi.fn(), + }, +})) + +vi.mock('jotai-tanstack-query', async (importOriginal) => { + const actual = await importOriginal() return { + ...actual, + atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => { + const options = createOptions(get) + const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined + const queryName = typeof queryKey === 'string' ? queryKey : 'unknown' + const queryResult = options.enabled === false + ? undefined + : mockQueryResults.current.get(queryName) + + return { + ...options, + data: undefined, + isError: false, + isFetching: false, + isLoading: false, + isSuccess: false, + ...queryResult, + } + }), + atomWithMutation: () => atom(() => mockCreateReleaseMutation.current), + } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + get: { + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['appById', input], + }), + }, + }, + }, + enterprise: { + releaseService: { + listReleaseSummaries: { + key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleaseSummaries'] : ['listReleaseSummaries', input], + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['listReleaseSummaries', input], + }), + }, + listReleases: { + key: ({ input }: { input?: unknown } = {}) => input === undefined ? ['listReleases'] : ['listReleases', input], + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['listReleases', input], + }), + }, + precheckRelease: { + queryOptions: ({ enabled, input }: QueryOptions) => ({ + enabled, + input, + queryKey: ['precheckRelease', input], + }), + }, + createRelease: { + mutationOptions: () => ({ mutationKey: ['createRelease'] }), + }, + }, + }, + }, +})) + +async function loadState() { + return await import('../index') +} + +async function mountedStore() { + const state = await loadState() + const store = createStore() + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + store.set(queryClientAtom, queryClient) + const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined) + + return { + queryClient, + state, store, unsubscribe, } @@ -34,6 +140,7 @@ function sourceApp(overrides: Partial { - it('should keep default form values before editing', () => { - const { store, unsubscribe } = mountedStore() +function setDefaultSourceApp(defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' })) { + mockQueryResults.current.set('listReleases', { + data: { + releases: [ + { + sourceAppId: defaultSourceApp.id, + }, + ], + }, + isSuccess: true, + }) + mockQueryResults.current.set('appById', { + data: defaultSourceApp, + isSuccess: true, + }) +} - expect(store.get(createReleaseFormValuesAtom)).toEqual({ +function setPrecheckReleaseResult(overrides: { + canCreate?: boolean + matchedRelease?: unknown + unsupportedNodes?: Array<{ id?: string, type?: string }> +} = {}) { + mockQueryResults.current.set('precheckRelease', { + data: { + gateCommitId: 'gate-commit-1', + canCreate: true, + unsupportedNodes: [], + ...overrides, + }, + isSuccess: true, + }) +} + +function setCachedReleaseSummaries(queryClient: QueryClient, appInstanceId: string, displayNames: string[]) { + queryClient.setQueryData( + consoleQuery.enterprise.releaseService.listReleaseSummaries.key({ + type: 'query', + input: { params: { appInstanceId } }, + }), + { + releaseSummaries: displayNames.map(displayName => ({ + release: { + displayName, + }, + })), + pagination: {}, + }, + ) +} + +function setDslFileContentResult(overrides: QueryResult = {}) { + mockQueryResults.current.set('createReleaseDslFileContent', { + data: workflowDsl(), + isSuccess: true, + ...overrides, + }) +} + +describe('create release state', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQueryResults.current.clear() + mockCreateReleaseMutation.current = { + isPending: false, + mutateAsync: vi.fn(), + } + }) + + it('should keep default form values before editing', async () => { + const { state, store, unsubscribe } = await mountedStore() + + expect(store.get(state.createReleaseFormValuesAtom)).toEqual({ dslFile: undefined, releaseDescription: '', releaseName: '', @@ -73,108 +247,243 @@ describe('create release state', () => { }) it('should validate release name only when submitting', async () => { - const { store, unsubscribe } = mountedStore() - const createRelease = vi.fn((_: CreateReleaseFormValues) => undefined) + const { state, store, unsubscribe } = await mountedStore() - await store.set(submitCreateReleaseFormAtom, createRelease) + await store.set(state.submitCreateReleaseFormAtom) - expect(createRelease).not.toHaveBeenCalled() + expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled() expect(hasValidationIssue( - store.get(createReleaseNameFieldAtom).meta?.errors ?? [], - RELEASE_NAME_REQUIRED_ERROR, + store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [], + state.RELEASE_NAME_REQUIRED_ERROR, )).toBe(true) unsubscribe() }) - it('should submit current form values when the release name is valid', async () => { - const { store, unsubscribe } = mountedStore() - const createRelease = vi.fn((_: CreateReleaseFormValues) => undefined) + it('should submit after fixing release name following a submit validation error', async () => { + const { state, store, unsubscribe } = await mountedStore() + const response = { + release: { + displayName: 'Release 1', + }, + } + mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() - store.set(createReleaseNameFieldAtom, 'Release 1') - store.set(createReleaseDescriptionFieldAtom, 'Initial rollout') + await store.set(state.submitCreateReleaseFormAtom) + store.set(state.createReleaseNameFieldAtom, 'Release 1') - await store.set(submitCreateReleaseFormAtom, createRelease) + const result = await store.set(state.submitCreateReleaseFormAtom) - expect(createRelease).toHaveBeenCalledTimes(1) - expect(createRelease).toHaveBeenCalledWith({ - dslFile: undefined, - releaseDescription: 'Initial rollout', - releaseName: 'Release 1', - releaseSourceMode: 'sourceApp', - sourceApp: undefined, - }) + expect(result).toBe(response) + expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledTimes(1) unsubscribe() }) - it('should clear source app and derive workflow DSL state when selecting a DSL file', async () => { - const { store, unsubscribe } = mountedStore() + it('should coerce DSL source mode to source app mode when DSL import is disabled', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.selectCreateReleaseSourceModeAtom, 'dsl') + + expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp') + expect(store.get(state.createReleaseSourceModeAtom)).toBe('sourceApp') + + unsubscribe() + }) + + it('should derive default source app selection from the latest release source', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + + expect(store.get(state.createReleaseSelectedSourceAppAtom)).toEqual({ + id: 'default-source-app', + name: 'Default Source App', + mode: 'workflow', + }) + expect(store.get(state.createReleaseSelectedSourceAppAtom)?.id).toBe('default-source-app') + + unsubscribe() + }) + + it('should derive workflow DSL read state when selecting a DSL file', async () => { + const { state, store, unsubscribe } = await mountedStore() const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' }) - store.set(updateCreateReleaseSourceAppAtom, sourceApp()) - store.set(selectCreateReleaseSourceModeAtom, 'dsl') - await store.set(updateCreateReleaseDslFileAtom, file) + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult() - const dslState = store.get(createReleaseDslStateAtom) - expect(store.get(createReleaseSourceModeFieldAtom).value).toBe('dsl') - expect(store.get(createReleaseSourceAppFieldAtom).value).toBeUndefined() - expect(store.get(createReleaseDslFileFieldAtom).value).toBe(file) - expect(dslState.dslContent).toBe(workflowDsl()) - expect(dslState.hasDslContent).toBe(true) - expect(dslState.isReadingDsl).toBe(false) - expect(dslState.isWorkflowDslContent).toBe(true) - expect(dslState.encodedDslContent).not.toBe('') + expect(store.get(state.createReleaseDslFileFieldAtom).value).toBe(file) + expect(store.get(state.createReleaseDslContentAtom)).toBe(workflowDsl()) + expect(store.get(state.createReleaseHasDslContentAtom)).toBe(true) + expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false) + expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(true) + expect(store.get(state.createReleaseEncodedDslContentAtom)).not.toBe('') unsubscribe() }) it('should reset DSL state when switching back to source app mode', async () => { - const { store, unsubscribe } = mountedStore() + const { state, store, unsubscribe } = await mountedStore() const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' }) - store.set(selectCreateReleaseSourceModeAtom, 'dsl') - await store.set(updateCreateReleaseDslFileAtom, file) - store.set(selectCreateReleaseSourceModeAtom, 'sourceApp') + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult() + store.set(state.selectCreateReleaseSourceModeAtom, 'sourceApp') - expect(store.get(createReleaseSourceModeFieldAtom).value).toBe('sourceApp') - expect(store.get(createReleaseDslFileFieldAtom).value).toBeUndefined() - expect(store.get(createReleaseDslStateAtom)).toEqual({ - dslContent: '', - dslReadError: false, - encodedDslContent: '', - hasDslContent: false, - isReadingDsl: false, - isWorkflowDslContent: false, - }) + expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp') + expect(store.get(state.createReleaseDslFileFieldAtom).value).toBeUndefined() + expect(store.get(state.createReleaseDslContentAtom)).toBe('') + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false) + expect(store.get(state.createReleaseEncodedDslContentAtom)).toBe('') + expect(store.get(state.createReleaseHasDslContentAtom)).toBe(false) + expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false) + expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(false) unsubscribe() }) it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => { - const { store, unsubscribe } = mountedStore() + const { state, store, unsubscribe } = await mountedStore() const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' }) - const readError = new Error('read failed') - Object.defineProperty(file, 'text', { - configurable: true, - value: vi.fn().mockRejectedValue(readError), + + store.set(state.updateCreateReleaseDslFileAtom, file) + setDslFileContentResult({ + data: undefined, + isError: true, + isSuccess: false, }) - await store.set(updateCreateReleaseDslFileAtom, file) + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(true) - expect(store.get(createReleaseDslStateAtom).dslReadError).toBe(true) - expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([]) + store.set(state.openCreateReleaseDialogAtom) + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(true) + expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false) - store.set(createReleaseSubmitUnsupportedDslNodesAtom, [{ id: 'node-1' }]) - store.set(openCreateReleaseDialogAtom) - expect(store.get(createReleaseDialogOpenAtom)).toBe(true) - expect(store.get(createReleaseDslStateAtom).dslReadError).toBe(false) - expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([]) + store.set(state.closeCreateReleaseDialogAtom) + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false) - store.set(createReleaseSubmitUnsupportedDslNodesAtom, [{ type: 'unsupported' }]) - store.set(closeCreateReleaseDialogAtom) - expect(store.get(createReleaseDialogOpenAtom)).toBe(false) - expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([]) + unsubscribe() + }) + + it('should derive content readiness from release content precheck', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + + expect(store.get(state.createReleaseContentReadyAtom)).toBe(true) + + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + expect(store.get(state.createReleaseContentReadyAtom)).toBe(true) + + unsubscribe() + }) + + it('should detect existing release name conflicts from cached release summaries', async () => { + const { queryClient, state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + store.set(state.createReleaseNameFieldAtom, ' Release 1 ') + setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1']) + + expect(store.get(state.createReleaseHasNameConflictAtom)).toBe(true) + + unsubscribe() + }) + + it('should close the dialog through the close request action', async () => { + const { state, store, unsubscribe } = await mountedStore() + + store.set(state.openCreateReleaseDialogAtom) + store.set(state.requestCloseCreateReleaseDialogAtom) + + expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false) + + unsubscribe() + }) + + it('should expose unsupported nodes from release content precheck', async () => { + const { state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult({ + canCreate: false, + unsupportedNodes: [{ id: 'precheck-node' }], + }) + + expect(store.get(state.createReleaseUnsupportedDslNodesAtom)).toEqual([{ id: 'precheck-node' }]) + + unsubscribe() + }) + + it('should submit source app release with the checked source and metadata', async () => { + const { state, store, unsubscribe } = await mountedStore() + const response = { + release: { + displayName: 'Release 1', + }, + } + mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + store.set(state.createReleaseNameFieldAtom, ' Release 1 ') + store.set(state.createReleaseDescriptionFieldAtom, ' Initial rollout ') + + const result = await store.set(state.submitCreateReleaseFormAtom) + + expect(result).toBe(response) + expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledWith({ + body: { + appInstanceId: 'app-instance-1', + sourceAppId: 'default-source-app', + displayName: 'Release 1', + description: 'Initial rollout', + createAppInstance: false, + }, + }) + + unsubscribe() + }) + + it('should block release submission when release name already exists', async () => { + const { queryClient, state, store, unsubscribe } = await mountedStore() + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + setCachedReleaseSummaries(queryClient, 'app-instance-1', ['Release 1']) + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + const result = await store.set(state.submitCreateReleaseFormAtom) + + expect(result).toBeUndefined() + expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled() + + unsubscribe() + }) + + it('should propagate create release submission errors', async () => { + const { state, store, unsubscribe } = await mountedStore() + const submitError = new Error('submit failed') + mockCreateReleaseMutation.current.mutateAsync.mockRejectedValue(submitError) + store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1') + store.set(state.openCreateReleaseDialogAtom) + setDefaultSourceApp() + setPrecheckReleaseResult() + store.set(state.createReleaseNameFieldAtom, 'Release 1') + + await expect(store.set(state.submitCreateReleaseFormAtom)).rejects.toThrow(submitError) unsubscribe() }) diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts index 8da9a12abc7..34570305926 100644 --- a/web/features/deployments/create-release/state/index.ts +++ b/web/features/deployments/create-release/state/index.ts @@ -1,17 +1,33 @@ '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 { SourceAppPickerValue } from '../ui/source-app-picker-value' -import { atom, useAtomValue } from 'jotai' +import type { App } from '@/types/app' +import { atom } from 'jotai' import { atomWithForm, createFormAtoms, } from 'jotai-tanstack-form' +import { + atomWithMutation, + atomWithQuery, + queryClientAtom, +} from 'jotai-tanstack-query' import * as z from 'zod' +import { consoleQuery } from '@/service/client' +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 @@ -30,6 +46,33 @@ const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = { export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired' +const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 + +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(), @@ -38,7 +81,7 @@ const createReleaseFormSchema = z.object({ releaseDescription: z.string(), }) -type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise | void +type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise | CreateReleaseResponse | undefined type CreateReleaseSubmitMeta = { createRelease: CreateReleaseSubmit @@ -46,6 +89,7 @@ type CreateReleaseSubmitMeta = { const noopCreateRelease: CreateReleaseSubmit = () => undefined +// Form state export const createReleaseFormAtom = atomWithForm({ defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES, onSubmitMeta: { @@ -61,108 +105,309 @@ const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom) export const createReleaseFormValuesAtom = createReleaseFormAtoms.valuesAtom export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom -export const setCreateReleaseFormFieldAtom = createReleaseFormAtoms.setFieldAtom 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') -export const submitCreateReleaseFormAtom = atom(null, (get, _set, createRelease: CreateReleaseSubmit) => { - const form = get(createReleaseFormAtom) - return form.api.handleSubmit({ createRelease } satisfies CreateReleaseSubmitMeta) -}) - -type CreateReleaseConfig = { - appInstanceId: string -} - -export type CreateReleaseDslState = { - dslContent: string - dslReadError: boolean - encodedDslContent: string - hasDslContent: boolean - isReadingDsl: boolean - isWorkflowDslContent: boolean -} - -export const createReleaseConfigAtom = atom(undefined) +// Dialog and source primitives +export const createReleaseAppInstanceIdAtom = atom(undefined) export const createReleaseDialogOpenAtom = atom(false) -export const createReleaseSubmitUnsupportedDslNodesAtom = atom([]) +const createReleaseDslFileReadVersionAtom = atom(0) -const createReleaseDslContentAtom = atom('') -const createReleaseDslReadErrorAtom = atom(false) -const createReleaseDslReadingAtom = atom(false) -const createReleaseDslReadTokenAtom = atom(0) +function requiredAppInstanceId(get: Getter) { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + if (!appInstanceId) + throw new Error('Missing create release app instance id.') -export const createReleaseLocalAtoms = [ - createReleaseDialogOpenAtom, - createReleaseDslContentAtom, - createReleaseDslReadErrorAtom, - createReleaseDslReadingAtom, - createReleaseDslReadTokenAtom, - createReleaseSubmitUnsupportedDslNodesAtom, -] as const + return appInstanceId +} -export const clearCreateReleaseSubmissionErrorAtom = atom(null, (_get, set) => { - set(createReleaseSubmitUnsupportedDslNodesAtom, []) +// Query and remote data +const latestSourceReleaseQueryAtom = atomWithQuery((get) => { + const appInstanceId = get(createReleaseAppInstanceIdAtom) + + return consoleQuery.enterprise.releaseService.listReleases.queryOptions({ + input: { + params: { appInstanceId: appInstanceId ?? '' }, + query: { + pageNumber: 1, + resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE, + }, + }, + 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: { + params: { app_id: latestSourceAppId ?? '' }, + }, + 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 { + 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 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: { + body: { + appInstanceId: appInstanceId ?? '', + ...(releaseSourceMode === 'dsl' + ? { dsl: get(createReleaseEncodedDslContentAtom) } + : { sourceAppId: sourceAppId ?? '' }), + }, + }, + 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(createReleaseDslReadTokenAtom, get(createReleaseDslReadTokenAtom) + 1) - set(createReleaseDslContentAtom, '') - set(createReleaseDslReadingAtom, false) - set(createReleaseDslReadErrorAtom, false) + set(createReleaseDslFileFieldAtom, undefined) + set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) }) export const openCreateReleaseDialogAtom = atom(null, (_get, set) => { - set(clearCreateReleaseSubmissionErrorAtom) set(resetCreateReleaseDslFileAtom) set(createReleaseDialogOpenAtom, true) }) export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => { set(createReleaseDialogOpenAtom, false) - set(clearCreateReleaseSubmissionErrorAtom) set(resetCreateReleaseDslFileAtom) }) -const selectCreateReleaseDslFileAtom = atom(null, async (get, set, file?: File) => { - const readToken = get(createReleaseDslReadTokenAtom) + 1 - set(createReleaseDslReadTokenAtom, readToken) - set(createReleaseDslContentAtom, '') - set(createReleaseDslReadingAtom, false) - set(createReleaseDslReadErrorAtom, false) - - if (!file) +export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => { + if (get(createReleaseFormIsSubmittingAtom)) return - set(createReleaseDslReadingAtom, true) - try { - const content = await file.text() - if (get(createReleaseDslReadTokenAtom) !== readToken) - return - - set(createReleaseDslContentAtom, content) - } - catch { - if (get(createReleaseDslReadTokenAtom) !== readToken) - return - - set(createReleaseDslReadErrorAtom, true) - } - finally { - if (get(createReleaseDslReadTokenAtom) === readToken) - set(createReleaseDslReadingAtom, false) - } + set(closeCreateReleaseDialogAtom) }) export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseSourceMode: ReleaseSourceMode) => { - set(clearCreateReleaseSubmissionErrorAtom) - set(createReleaseSourceModeFieldAtom, releaseSourceMode) + const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode) + set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode) - if (releaseSourceMode === 'sourceApp') { - set(createReleaseDslFileFieldAtom, undefined) + if (effectiveReleaseSourceMode === 'sourceApp') { set(resetCreateReleaseDslFileAtom) return } @@ -172,34 +417,94 @@ export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseS export const updateCreateReleaseSourceAppAtom = atom(null, (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => { set(createReleaseSourceAppFieldAtom, sourceApp) - set(clearCreateReleaseSubmissionErrorAtom) }) export const updateCreateReleaseDslFileAtom = atom(null, (get, set, dslFile: CreateReleaseFormValues['dslFile']) => { set(createReleaseDslFileFieldAtom, dslFile) - set(clearCreateReleaseSubmissionErrorAtom) - return set(selectCreateReleaseDslFileAtom, dslFile) + set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1) }) -export const createReleaseDslStateAtom = atom((get): CreateReleaseDslState => { - const dslContent = get(createReleaseDslContentAtom) - const hasDslContent = Boolean(dslContent.trim()) - const isWorkflowDslContent = hasDslContent ? isWorkflowDsl(dslContent) : false +// Submission +const createReleaseMutationAtom = atomWithMutation(() => + consoleQuery.enterprise.releaseService.createRelease.mutationOptions(), +) - return { - dslContent, - dslReadError: get(createReleaseDslReadErrorAtom), - encodedDslContent: hasDslContent && isWorkflowDslContent ? encodeDslContent(dslContent) : '', - hasDslContent, - isReadingDsl: get(createReleaseDslReadingAtom), - isWorkflowDslContent, +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 function useCreateReleaseConfig() { - const config = useAtomValue(createReleaseConfigAtom) - if (!config) - throw new Error('Missing create release config.') +export const submitCreateReleaseFormAtom = atom(null, (get, set) => { + const form = get(createReleaseFormAtom) + let submitResponse: CreateReleaseResponse | undefined - return config -} + 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, +] as const 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 5876a3c5d4f..dbf553b8d1f 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 @@ -17,7 +17,6 @@ function renderSourceAppPicker(disabled: boolean) { undefined} - ariaLabel="Source app" disabled={disabled} /> , @@ -29,6 +28,6 @@ describe('SourceAppPicker', () => { renderSourceAppPicker(true) expect(screen.getByText('Workflow 1')).toBeInTheDocument() - expect(screen.getByRole('combobox', { name: 'Source app' })).toBeDisabled() + expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).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 deleted file mode 100644 index e9ca69fc366..00000000000 --- a/web/features/deployments/create-release/ui/__tests__/use-release-content-check.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/actions.tsx b/web/features/deployments/create-release/ui/actions.tsx index c99e251a4ff..40558eedefe 100644 --- a/web/features/deployments/create-release/ui/actions.tsx +++ b/web/features/deployments/create-release/ui/actions.tsx @@ -4,35 +4,23 @@ import { Button } from '@langgenius/dify-ui/button' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' import { - closeCreateReleaseDialogAtom, + createReleaseContentReadyAtom, createReleaseFormIsSubmittingAtom, - createReleaseFormValuesAtom, + createReleaseHasNameConflictAtom, + createReleaseNameFieldAtom, + isCheckingCreateReleaseContentAtom, + requestCloseCreateReleaseDialogAtom, } from '../state' -import { - createReleaseReadiness, - useCreateReleaseSourceSelection, - useReleaseContentCheck, -} from './use-release-content-check' export function CreateReleaseActions() { const { t } = useTranslation('deployments') - const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) - const formValues = useAtomValue(createReleaseFormValuesAtom) + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom) - const sourceSelection = useCreateReleaseSourceSelection(formValues) - const releaseContent = useReleaseContentCheck(sourceSelection) - const { canCreate, isCheckingReleaseContent } = createReleaseReadiness({ - formValues, - isSubmitting, - releaseContent, - }) - - function requestClose() { - if (isSubmitting) - return - - closeDialog() - } + const releaseContentReady = useAtomValue(createReleaseContentReadyAtom) + const isCheckingReleaseContent = useAtomValue(isCheckingCreateReleaseContentAtom) + const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom) + const releaseNameField = useAtomValue(createReleaseNameFieldAtom) + const hasReleaseName = Boolean(releaseNameField.value.trim()) return (
@@ -44,12 +32,12 @@ export function CreateReleaseActions() { onPointerDown={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} onClick={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} > {t('versions.cancelCreate')} @@ -58,7 +46,8 @@ export function CreateReleaseActions() { type="submit" variant="primary" className="min-w-22" - disabled={!canCreate} + disabled={!hasReleaseName || !releaseContentReady || hasReleaseNameConflict} + loading={isSubmitting} > {isSubmitting ? t('versions.creating') : isCheckingReleaseContent ? t('versions.checkingReleaseContent') : t('versions.create')} diff --git a/web/features/deployments/create-release/ui/content-feedback.tsx b/web/features/deployments/create-release/ui/content-feedback.tsx index eb9cc4b8fb4..8e7b2e323f4 100644 --- a/web/features/deployments/create-release/ui/content-feedback.tsx +++ b/web/features/deployments/create-release/ui/content-feedback.tsx @@ -4,44 +4,28 @@ import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert' import { - createReleaseFormValuesAtom, - createReleaseSubmitUnsupportedDslNodesAtom, + createReleaseContentCheckFailedAtom, + createReleaseMatchedReleaseAtom, + createReleaseUnsupportedDslNodesAtom, } from '../state' -import { - useCreateReleaseSourceSelection, - useReleaseContentCheck, -} from './use-release-content-check' export function ReleaseContentFeedback() { const { t } = useTranslation('deployments') - const formValues = useAtomValue(createReleaseFormValuesAtom) - const sourceSelection = useCreateReleaseSourceSelection(formValues) - const releaseContent = useReleaseContentCheck(sourceSelection) - const submitUnsupportedDslNodes = useAtomValue(createReleaseSubmitUnsupportedDslNodesAtom) - // Precheck reports unsupported nodes at pick time; the post-submit atom stays - // as the TOCTOU fallback when the content changes server-side between - // precheck and create. - const unsupportedDslNodes = releaseContent.unsupportedNodes.length > 0 - ? releaseContent.unsupportedNodes - : submitUnsupportedDslNodes + const unsupportedDslNodes = useAtomValue(createReleaseUnsupportedDslNodesAtom) + const matchedRelease = useAtomValue(createReleaseMatchedReleaseAtom) + const releaseContentCheckFailed = useAtomValue(createReleaseContentCheckFailedAtom) return ( <> - {releaseContent.isCheckingReleaseContent && ( -
- {t('versions.checkingReleaseContent')} -
- )} - - {releaseContent.matchedRelease && ( + {matchedRelease && (
- {t('versions.releaseAlreadyExists', { name: releaseContent.matchedRelease.displayName })} + {t('versions.releaseAlreadyExists', { name: matchedRelease.displayName })}
)} - {releaseContent.releaseContentCheckFailed && ( + {releaseContentCheckFailed && (
{t('versions.releaseContentCheckFailed')}
diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx index bc868228334..a0b6dde2fc6 100644 --- a/web/features/deployments/create-release/ui/dialog.tsx +++ b/web/features/deployments/create-release/ui/dialog.tsx @@ -1,45 +1,27 @@ 'use client' -import type { CreateReleaseFormValues } from '../state' -import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' -import { skipToken, useQuery } from '@tanstack/react-query' +import { DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' -import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { consoleQuery } from '@/service/client' -import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags' +import { deploymentErrorMessage } from '../../shared/domain/error' import { closeCreateReleaseDialogAtom, - createReleaseDialogOpenAtom, createReleaseFormAtom, createReleaseFormIsSubmittingAtom, - createReleaseFormValuesAtom, - openCreateReleaseDialogAtom, - setCreateReleaseFormFieldAtom, + CreateReleaseSubmissionBlockedError, + requestCloseCreateReleaseDialogAtom, submitCreateReleaseFormAtom, - useCreateReleaseConfig, } from '../state' import { CreateReleaseActions } from './actions' import { ReleaseContentFeedback } from './content-feedback' import { ReleaseMetadataFields } from './metadata-fields' -import { workflowSourceAppPickerValue } from './source-app-picker-value' import { ReleaseSourceSection } from './source-section' -import { useCreateReleaseSubmission } from './use-create-release-submission' -const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1 - -function CreateReleaseCloseButton({ isSubmitting }: { - isSubmitting: boolean -}) { - const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) - - function requestClose() { - if (isSubmitting) - return - - closeDialog() - } +function CreateReleaseCloseButton() { + const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom) + const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom) return ( { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} onClick={(event) => { event.preventDefault() event.stopPropagation() - requestClose() + requestCloseDialog() }} /> ) } -function CreateReleaseDefaultSourceApp({ formValues }: { - formValues: CreateReleaseFormValues -}) { - const { appInstanceId } = useCreateReleaseConfig() - const setCreateReleaseFormField = useSetAtom(setCreateReleaseFormFieldAtom) - const isDialogOpen = useAtomValue(createReleaseDialogOpenAtom) - const latestReleaseQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({ - input: { - params: { appInstanceId }, - query: { - pageNumber: 1, - resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE, - }, - }, - enabled: isDialogOpen, - })) - const latestSourceAppId = latestReleaseQuery.data?.releases[0]?.sourceAppId - const defaultSourceAppInput = isDialogOpen && latestSourceAppId - ? { params: { app_id: latestSourceAppId } } - : undefined - const defaultSourceAppQuery = useQuery(defaultSourceAppInput - ? consoleQuery.apps.byAppId.get.queryOptions({ - input: defaultSourceAppInput, - }) - : { - queryFn: skipToken, - queryKey: ['create-release', 'default-source-app'], - }) - const defaultSourceApp = latestSourceAppId - ? workflowSourceAppPickerValue(defaultSourceAppQuery.data, latestSourceAppId) - : undefined - const sourceAppLocked = !isDeploymentDslImportEnabled - const releaseSourceMode = formValues.releaseSourceMode === 'dsl' && !isDeploymentDslImportEnabled - ? 'sourceApp' - : formValues.releaseSourceMode - - useEffect(() => { - if (!isDialogOpen || releaseSourceMode !== 'sourceApp' || !defaultSourceApp) - return - if (formValues.sourceApp && (!sourceAppLocked || formValues.sourceApp.id === defaultSourceApp.id)) - return - - setCreateReleaseFormField({ name: 'sourceApp', value: defaultSourceApp }) - }, [defaultSourceApp, formValues.sourceApp, isDialogOpen, releaseSourceMode, setCreateReleaseFormField, sourceAppLocked]) - - return null -} - -function CreateReleaseDialogForm() { +export function CreateReleaseDialogContent() { return ( @@ -116,71 +50,61 @@ function CreateReleaseDialogForm() { } function CreateReleaseDialogSurface() { - const open = useAtomValue(createReleaseDialogOpenAtom) - const formValues = useAtomValue(createReleaseFormValuesAtom) - const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom) - const openDialog = useSetAtom(openCreateReleaseDialogAtom) const closeDialog = useSetAtom(closeCreateReleaseDialogAtom) const submitCreateReleaseForm = useSetAtom(submitCreateReleaseFormAtom) const { t } = useTranslation('deployments') - const submission = useCreateReleaseSubmission(formValues) - function handleDialogOpenChange(nextOpen: boolean) { - if (nextOpen) { - openDialog() - return - } + async function handleSubmit() { + try { + const response = await submitCreateReleaseForm() + if (!response) + return - if (!isSubmitting) + toast.success(t('versions.createSuccess', { name: response.release.displayName })) closeDialog() + } + catch (error) { + if (error instanceof CreateReleaseSubmissionBlockedError) { + toast.error(t('versions.dslUnsupportedMode')) + return + } + + const message = await deploymentErrorMessage(error) + toast.error(message || t('versions.createFailed')) + } } return ( - - - - -
{ - event.preventDefault() - event.stopPropagation() - void submitCreateReleaseForm(submission.createRelease) - }} - > -
-
- - {t('versions.createRelease')} - - - {t('versions.createReleaseDescription')} - -
+ + + { + event.preventDefault() + event.stopPropagation() + void handleSubmit() + }} + > +
+
+ + {t('versions.createRelease')} + + + {t('versions.createReleaseDescription')} +
+
-
- - - -
+
+ + + +
- - -
-
+ + + ) } - -export function CreateReleaseDialog() { - const open = useAtomValue(createReleaseDialogOpenAtom) - - if (!open) - return null - - return -} diff --git a/web/features/deployments/create-release/ui/metadata-fields.tsx b/web/features/deployments/create-release/ui/metadata-fields.tsx index fa55e91628f..0144fae1ec3 100644 --- a/web/features/deployments/create-release/ui/metadata-fields.tsx +++ b/web/features/deployments/create-release/ui/metadata-fields.tsx @@ -3,11 +3,12 @@ import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' import { Textarea } from '@langgenius/dify-ui/textarea' -import { useAtom } from 'jotai' +import { useAtom, useAtomValue } from 'jotai' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { createReleaseDescriptionFieldAtom, + createReleaseHasNameConflictAtom, createReleaseNameFieldAtom, RELEASE_NAME_REQUIRED_ERROR, } from '../state' @@ -40,8 +41,15 @@ export function ReleaseMetadataFields() { const { t } = useTranslation('deployments') const [releaseNameField, setReleaseNameField] = useAtom(createReleaseNameFieldAtom) const [releaseDescriptionField, setReleaseDescriptionField] = useAtom(createReleaseDescriptionFieldAtom) + const hasReleaseNameConflict = useAtomValue(createReleaseHasNameConflictAtom) const releaseNameInputRef = useRef(null) const releaseNameErrors = releaseNameField.meta?.errors ?? [] + const hasReleaseNameRequired = hasReleaseNameRequiredError(releaseNameErrors) + const releaseNameError = hasReleaseNameRequired + ? t('versions.releaseNameRequired') + : hasReleaseNameConflict + ? t('versions.releaseNameConflict') + : '' useEffect(() => { releaseNameInputRef.current?.focus() @@ -61,16 +69,16 @@ export function ReleaseMetadataFields() { maxLength={128} autoComplete="off" value={releaseNameField.value} - aria-invalid={hasReleaseNameRequiredError(releaseNameErrors) || undefined} - aria-describedby={hasReleaseNameRequiredError(releaseNameErrors) ? 'release-name-error' : undefined} + aria-invalid={Boolean(releaseNameError) || undefined} + aria-describedby={releaseNameError ? 'release-name-error' : undefined} onChange={(event) => { setReleaseNameField(event.target.value) }} className="h-9" /> - {hasReleaseNameRequiredError(releaseNameErrors) && ( + {releaseNameError && ( )}
diff --git a/web/features/deployments/create-release/ui/source-app-mode.ts b/web/features/deployments/create-release/ui/source-app-mode.ts deleted file mode 100644 index 478417e92a3..00000000000 --- a/web/features/deployments/create-release/ui/source-app-mode.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AppModeEnum } from '@/types/app' - -type WorkflowAppMode = Extract - -export function isWorkflowAppMode(mode?: string | null): mode is WorkflowAppMode { - return mode === AppModeEnum.WORKFLOW -} - -export function isWorkflowApp(app?: T): app is T & { mode: WorkflowAppMode } { - return isWorkflowAppMode(app?.mode) -} diff --git a/web/features/deployments/create-release/ui/source-app-picker-value.ts b/web/features/deployments/create-release/ui/source-app-picker-value.ts deleted file mode 100644 index dee3375d784..00000000000 --- a/web/features/deployments/create-release/ui/source-app-picker-value.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { App } from '@/types/app' -import { isWorkflowAppMode } from './source-app-mode' - -export type SourceAppPickerValue = Pick & Partial> - -export 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 (!isWorkflowAppMode(mode)) - 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, - } -} 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 1d37fe749e7..e8b1415fdb7 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -1,5 +1,5 @@ 'use client' -import type { SourceAppPickerValue } from './source-app-picker-value' +import type { SourceAppPickerValue } from '../state' import type { App } from '@/types/app' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -22,7 +22,6 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' import { consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' import { TitleTooltip } from '../../components/title-tooltip' -import { isWorkflowApp } from './source-app-mode' const SOURCE_APP_PAGE_SIZE = 20 const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app'] @@ -31,21 +30,18 @@ function sourceAppSearchText(app: App) { return `${app.name} ${app.id}`.toLowerCase() } -function SourceAppTrigger({ open, app, disabled }: { - open: boolean +function SourceAppTrigger({ app }: { app?: SourceAppPickerValue - disabled: boolean }) { const { t } = useTranslation('deployments') return ( @@ -73,9 +69,9 @@ function SourceAppTrigger({ open, app, disabled }: {