refactor(web): consolidate create release state (#37765)

This commit is contained in:
Stephen Zhou 2026-06-23 10:17:54 +08:00 committed by GitHub
parent b67a04aa22
commit f380bbaa10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1213 additions and 740 deletions

View File

@ -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

View File

@ -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()

View File

@ -106,6 +106,44 @@ function createFormInstance<TValues, TSubmitMeta = never>(
}
}
function setFormFieldValue<
TValues,
TSubmitMeta,
TField extends DeepKeys<TValues>,
>(
form: FormAtomInstance<TValues, TSubmitMeta>,
name: TField,
value: Updater<DeepValue<TValues, TField>>,
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<TValues, TSubmitMeta = never>(
options: FormOptionsInput<TValues, TSubmitMeta>,
): FormAtom<TValues, TSubmitMeta> {
@ -129,7 +167,7 @@ export function createFormAtoms<TValues, TSubmitMeta = never>(
})
const setFieldAtom = atom<null, [FormFieldUpdate<TValues>], 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<TField extends DeepKeys<TValues>>(
@ -151,7 +189,7 @@ export function createFormAtoms<TValues, TSubmitMeta = never>(
}
},
(get, _set, value, options) => {
get(formAtom).api.setFieldValue(name, value, options)
setFormFieldValue(get(formAtom), name, value, options)
},
)
}

View File

@ -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 (
<Button
size={size}
variant={variant}
className={className}
onClick={openDialog}
<Dialog
open={open}
onOpenChange={handleDialogOpenChange}
>
{label ?? t('versions.createRelease')}
</Button>
<DialogTrigger
render={(
<Button
size={size}
variant={variant}
className={className}
/>
)}
>
{label ?? t('versions.createRelease')}
</DialogTrigger>
{open && <CreateReleaseDialogContent />}
</Dialog>
)
}
@ -55,18 +81,17 @@ export function CreateReleaseControl({
<ScopeProvider
key={appInstanceId}
atoms={[
[createReleaseConfigAtom, { appInstanceId }],
[createReleaseAppInstanceIdAtom, appInstanceId],
...createReleaseLocalAtoms,
]}
name="CreateRelease"
>
<CreateReleaseTrigger
<CreateReleaseScopedControl
variant={variant}
size={size}
label={label}
className={className}
/>
<CreateReleaseDialog />
</ScopeProvider>
)
}

View File

@ -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<typeof vi.fn>
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutateAsync: vi.fn(),
},
}))
vi.mock('jotai-tanstack-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai-tanstack-query')>()
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<NonNullable<CreateReleaseFormValues['sourc
return {
id: 'source-app-1',
name: 'Source App',
mode: 'workflow',
...overrides,
}
}
@ -57,11 +164,78 @@ function workflowDsl() {
].join('\n')
}
describe('create release state', () => {
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()
})

View File

@ -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<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>
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<string, unknown>
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<CreateReleaseFormValues['sourceApp']>().optional(),
@ -38,7 +81,7 @@ const createReleaseFormSchema = z.object({
releaseDescription: z.string(),
})
type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise<void> | void
type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise<CreateReleaseResponse | undefined> | 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<CreateReleaseConfig | undefined>(undefined)
// Dialog and source primitives
export const createReleaseAppInstanceIdAtom = atom<string | undefined>(undefined)
export const createReleaseDialogOpenAtom = atom(false)
export const createReleaseSubmitUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
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<ListReleaseSummariesResponse>({
queryKey: consoleQuery.enterprise.releaseService.listReleaseSummaries.key({
type: 'query',
input: { params: { appInstanceId } },
}),
})
const releaseQueries = queryClient.getQueriesData<ListReleasesResponse>({
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

View File

@ -17,7 +17,6 @@ function renderSourceAppPicker(disabled: boolean) {
<SourceAppPicker
value={{ id: 'app-1', name: 'Workflow 1' }}
onChange={() => undefined}
ariaLabel="Source app"
disabled={disabled}
/>
</QueryClientProvider>,
@ -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()
})
})

View File

@ -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> = {}): 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)
})
})

View File

@ -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 (
<div className="flex items-center justify-end gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
@ -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')}
</Button>

View File

@ -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 (
<>
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
{releaseContent.isCheckingReleaseContent && (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{t('versions.checkingReleaseContent')}
</div>
)}
{releaseContent.matchedRelease && (
{matchedRelease && (
<div role="alert" className="rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2 system-sm-regular text-util-colors-warning-warning-700">
{t('versions.releaseAlreadyExists', { name: releaseContent.matchedRelease.displayName })}
{t('versions.releaseAlreadyExists', { name: matchedRelease.displayName })}
</div>
)}
{releaseContent.releaseContentCheckFailed && (
{releaseContentCheckFailed && (
<div role="alert" className="rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700">
{t('versions.releaseContentCheckFailed')}
</div>

View File

@ -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 (
<DialogCloseButton
@ -48,66 +30,18 @@ function CreateReleaseCloseButton({ isSubmitting }: {
onPointerDown={(event) => {
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 (
<ScopeProvider atoms={[createReleaseFormAtom]}>
<CreateReleaseDialogSurface />
@ -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 (
<Dialog
open={open}
onOpenChange={handleDialogOpenChange}
>
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<CreateReleaseDefaultSourceApp formValues={formValues} />
<CreateReleaseCloseButton isSubmitting={isSubmitting} />
<form
noValidate
autoComplete="off"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void submitCreateReleaseForm(submission.createRelease)
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<CreateReleaseCloseButton />
<form
noValidate
autoComplete="off"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void handleSubmit()
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<ReleaseSourceSection />
<ReleaseContentFeedback />
<ReleaseMetadataFields />
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<ReleaseSourceSection />
<ReleaseContentFeedback />
<ReleaseMetadataFields />
</div>
<CreateReleaseActions />
</form>
</DialogContent>
</Dialog>
<CreateReleaseActions />
</form>
</DialogContent>
)
}
export function CreateReleaseDialog() {
const open = useAtomValue(createReleaseDialogOpenAtom)
if (!open)
return null
return <CreateReleaseDialogForm />
}

View File

@ -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<HTMLInputElement>(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 && (
<div id="release-name-error" role="alert" className="system-xs-regular text-text-destructive">
{t('versions.releaseNameRequired')}
{releaseNameError}
</div>
)}
</div>

View File

@ -1,11 +0,0 @@
import { AppModeEnum } from '@/types/app'
type WorkflowAppMode = Extract<AppModeEnum, 'workflow'>
export function isWorkflowAppMode(mode?: string | null): mode is WorkflowAppMode {
return mode === AppModeEnum.WORKFLOW
}
export function isWorkflowApp<T extends { mode?: string | null }>(app?: T): app is T & { mode: WorkflowAppMode } {
return isWorkflowAppMode(app?.mode)
}

View File

@ -1,23 +0,0 @@
import type { App } from '@/types/app'
import { isWorkflowAppMode } from './source-app-mode'
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>
export function workflowSourceAppPickerValue(value: unknown, fallbackId: string): SourceAppPickerValue | undefined {
if (!value || typeof value !== 'object')
return undefined
const record = value as Record<string, unknown>
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,
}
}

View File

@ -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 (
<span
className={cn(
'group flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
disabled
? 'cursor-not-allowed text-components-input-text-disabled'
: 'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
open && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
'flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'group-data-disabled/combobox-trigger:cursor-not-allowed group-data-disabled/combobox-trigger:text-components-input-text-disabled group-data-disabled/combobox-trigger:hover:border-transparent group-data-disabled/combobox-trigger:hover:bg-components-input-bg-normal',
'group-data-popup-open/combobox-trigger:border-components-input-border-active group-data-popup-open/combobox-trigger:bg-components-input-bg-active group-data-popup-open/combobox-trigger:shadow-xs',
app && 'pl-2',
)}
>
@ -73,9 +69,9 @@ function SourceAppTrigger({ open, app, disabled }: {
</TitleTooltip>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
disabled && 'opacity-50 group-hover:text-text-quaternary',
open && 'text-text-secondary',
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover/combobox-trigger:text-text-secondary',
'group-data-disabled/combobox-trigger:text-text-quaternary group-data-disabled/combobox-trigger:opacity-50',
'group-data-popup-open/combobox-trigger:text-text-secondary',
)}
aria-hidden="true"
/>
@ -128,10 +124,9 @@ function SourceAppPickerSkeleton() {
)
}
export function SourceAppPicker({ value, onChange, ariaLabel, disabled = false }: {
export function SourceAppPicker({ value, onChange, disabled = false }: {
value?: SourceAppPickerValue
onChange: (app: App) => void
ariaLabel?: string
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
@ -161,7 +156,7 @@ export function SourceAppPicker({ value, onChange, ariaLabel, disabled = false }
enabled: !disabled,
})
const apps = data?.pages.flatMap(page => page.data).filter(isWorkflowApp) ?? []
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
@ -199,11 +194,11 @@ export function SourceAppPicker({ value, onChange, ariaLabel, disabled = false }
disabled={disabled}
>
<ComboboxTrigger
aria-label={ariaLabel ?? t('createModal.sourceApp')}
aria-label={t('versions.sourceAppOption')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={!disabled && isShow} app={value} disabled={disabled} />
<SourceAppTrigger app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"

View File

@ -8,9 +8,11 @@ import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDslFileFieldAtom,
createReleaseDslStateAtom,
createReleaseSourceAppFieldAtom,
createReleaseSourceModeFieldAtom,
createReleaseDslReadErrorAtom,
createReleaseHasUnsupportedDslModeAtom,
createReleaseSelectedSourceAppAtom,
createReleaseSourceModeAtom,
isReadingCreateReleaseDslAtom,
selectCreateReleaseSourceModeAtom,
updateCreateReleaseDslFileAtom,
updateCreateReleaseSourceAppAtom,
@ -23,7 +25,7 @@ function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefin
export function ReleaseSourceSection() {
const { t } = useTranslation('deployments')
const sourceModeField = useAtomValue(createReleaseSourceModeFieldAtom)
const releaseSourceMode = useAtomValue(createReleaseSourceModeAtom)
const selectReleaseSourceMode = useSetAtom(selectCreateReleaseSourceModeAtom)
return (
@ -35,10 +37,10 @@ export function ReleaseSourceSection() {
{isDeploymentDslImportEnabled && (
<SegmentedControl<ReleaseSourceMode>
aria-labelledby="release-source-mode-label"
value={[sourceModeField.value]}
value={[releaseSourceMode]}
onValueChange={(value) => {
const nextMode = selectedReleaseSourceMode(value)
if (!nextMode || nextMode === sourceModeField.value)
if (!nextMode || nextMode === releaseSourceMode)
return
selectReleaseSourceMode(nextMode)
@ -58,7 +60,7 @@ export function ReleaseSourceSection() {
</div>
<div className="min-h-12">
{sourceModeField.value === 'sourceApp' || !isDeploymentDslImportEnabled
{releaseSourceMode === 'sourceApp'
? <SourceAppField />
: <DslFileField />}
</div>
@ -67,17 +69,15 @@ export function ReleaseSourceSection() {
}
function SourceAppField() {
const { t } = useTranslation('deployments')
const sourceAppField = useAtomValue(createReleaseSourceAppFieldAtom)
const sourceApp = useAtomValue(createReleaseSelectedSourceAppAtom)
const updateSourceApp = useSetAtom(updateCreateReleaseSourceAppAtom)
const sourceAppLocked = !isDeploymentDslImportEnabled
return (
<div className="flex min-h-12 items-center">
<SourceAppPicker
value={sourceAppField.value}
value={sourceApp}
onChange={updateSourceApp}
ariaLabel={t('versions.sourceAppOption')}
disabled={sourceAppLocked}
/>
</div>
@ -87,7 +87,8 @@ function SourceAppField() {
function DslFileField() {
const { t } = useTranslation('deployments')
const dslFileField = useAtomValue(createReleaseDslFileFieldAtom)
const dslState = useAtomValue(createReleaseDslStateAtom)
const isReadingDsl = useAtomValue(isReadingCreateReleaseDslAtom)
const dslReadError = useAtomValue(createReleaseDslReadErrorAtom)
const updateDslFile = useSetAtom(updateCreateReleaseDslFileAtom)
return (
@ -99,12 +100,12 @@ function DslFileField() {
}}
className="mt-0"
/>
{dslState.isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{isReadingDsl && (
<div role="status" className="system-xs-regular text-text-tertiary">
{t('versions.dslReading')}
</div>
)}
{dslState.dslReadError && (
{dslReadError && (
<div role="alert" className="system-xs-regular text-util-colors-red-red-600">
{t('versions.dslReadFailed')}
</div>
@ -116,11 +117,7 @@ function DslFileField() {
function DslUnsupportedModeError() {
const { t } = useTranslation('deployments')
const dslState = useAtomValue(createReleaseDslStateAtom)
const hasUnsupportedDslMode = dslState.hasDslContent
&& !dslState.isReadingDsl
&& !dslState.dslReadError
&& !dslState.isWorkflowDslContent
const hasUnsupportedDslMode = useAtomValue(createReleaseHasUnsupportedDslModeAtom)
if (!hasUnsupportedDslMode)
return null

View File

@ -1,109 +0,0 @@
'use client'
import type { CreateReleaseResponse } from '@dify/contracts/enterprise/types.gen'
import type { CreateReleaseFormValues } from '../state'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../shared/domain/error'
import {
clearCreateReleaseSubmissionErrorAtom,
closeCreateReleaseDialogAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
useCreateReleaseConfig,
} from '../state'
import {
canCheckReleaseSourceContent,
useCreateReleaseSourceSelection,
useReleaseContentCheck,
} from './use-release-content-check'
export function useCreateReleaseSubmission(formValues: CreateReleaseFormValues) {
const { t } = useTranslation('deployments')
const { appInstanceId } = useCreateReleaseConfig()
const sourceSelection = useCreateReleaseSourceSelection(formValues)
const releaseContent = useReleaseContentCheck(sourceSelection)
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
const createReleaseMutation = useMutation(consoleQuery.enterprise.releaseService.createRelease.mutationOptions())
const clearSubmitError = useSetAtom(clearCreateReleaseSubmissionErrorAtom)
const setUnsupportedDslNodes = useSetAtom(createReleaseSubmitUnsupportedDslNodesAtom)
function clearSubmissionError() {
createReleaseMutation.reset()
clearSubmitError()
}
function handleSuccess(response: CreateReleaseResponse) {
const createdName = response.release.displayName
toast.success(t('versions.createSuccess', { name: createdName }))
closeDialog()
}
async function handleError(error: unknown) {
const unsupportedError = await unsupportedDslNodeError(error)
if (unsupportedError?.nodes.length) {
setUnsupportedDslNodes(unsupportedError.nodes)
return
}
const message = await deploymentErrorMessage(error)
toast.error(message || t('versions.createFailed'))
}
async function createRelease(value: CreateReleaseFormValues) {
if (releaseContent.isCheckingReleaseContent)
return
const submittedReleaseName = value.releaseName.trim()
if (!submittedReleaseName)
return
clearSubmissionError()
try {
if (!canCheckReleaseSourceContent(sourceSelection) || !releaseContent.releaseContentReady)
return
if (sourceSelection.releaseSourceMode === 'dsl') {
if (!sourceSelection.isWorkflowDslContent) {
toast.error(t('versions.dslUnsupportedMode'))
return
}
const response = await createReleaseMutation.mutateAsync({
body: {
appInstanceId,
dsl: sourceSelection.encodedDslContent,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
},
})
handleSuccess(response)
return
}
if (!sourceSelection.selectedSourceAppId)
return
const response = await createReleaseMutation.mutateAsync({
body: {
appInstanceId,
sourceAppId: sourceSelection.selectedSourceAppId,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
},
})
handleSuccess(response)
}
catch (error) {
await handleError(error)
}
}
return {
createRelease,
}
}

View File

@ -1,129 +0,0 @@
'use client'
import type { CreateReleaseDslState, CreateReleaseFormValues, ReleaseSourceMode } from '../state'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { consoleQuery } from '@/service/client'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDialogOpenAtom,
createReleaseDslStateAtom,
useCreateReleaseConfig,
} from '../state'
export type CreateReleaseSourceSelection = CreateReleaseDslState & {
hasUnsupportedDslMode: boolean
releaseSourceMode: ReleaseSourceMode
selectedSourceAppId?: string
}
function createReleaseSourceSelection(
formValues: CreateReleaseFormValues,
dslState: CreateReleaseDslState,
): CreateReleaseSourceSelection {
const releaseSourceMode = formValues.releaseSourceMode === 'dsl' && !isDeploymentDslImportEnabled
? 'sourceApp'
: formValues.releaseSourceMode
const hasUnsupportedDslMode = releaseSourceMode === 'dsl'
&& dslState.hasDslContent
&& !dslState.isReadingDsl
&& !dslState.dslReadError
&& !dslState.isWorkflowDslContent
const selectedSourceAppId = releaseSourceMode === 'sourceApp' ? formValues.sourceApp?.id : undefined
return {
...dslState,
hasUnsupportedDslMode,
releaseSourceMode,
selectedSourceAppId,
}
}
export function canCheckReleaseSourceContent(releaseSource: CreateReleaseSourceSelection) {
if (releaseSource.releaseSourceMode === 'sourceApp')
return Boolean(releaseSource.selectedSourceAppId)
if (!isDeploymentDslImportEnabled)
return false
return Boolean(
releaseSource.hasDslContent
&& !releaseSource.isReadingDsl
&& !releaseSource.dslReadError
&& !releaseSource.hasUnsupportedDslMode,
)
}
export function useReleaseContentCheck(releaseSource: CreateReleaseSourceSelection) {
const { appInstanceId } = useCreateReleaseConfig()
const isDialogOpen = useAtomValue(createReleaseDialogOpenAtom)
const canCheckReleaseContent = isDialogOpen && canCheckReleaseSourceContent(releaseSource)
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
const precheckSource = releaseSource.releaseSourceMode === 'sourceApp'
? (releaseSource.selectedSourceAppId ? { sourceAppId: releaseSource.selectedSourceAppId } : undefined)
: { dsl: releaseSource.encodedDslContent }
const precheckInput = canCheckReleaseContent && precheckSource
? {
body: {
appInstanceId,
...precheckSource,
},
}
: undefined
const precheckQuery = useQuery({
...(precheckInput
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: precheckInput,
})
: {
queryFn: skipToken,
queryKey: ['create-release', 'release-precheck'],
}),
retry: false,
})
const matchedRelease = canCheckReleaseContent ? precheckQuery.data?.matchedRelease : undefined
const unsupportedNodes = (canCheckReleaseContent ? precheckQuery.data?.unsupportedNodes : undefined) ?? []
const isCheckingReleaseContent = canCheckReleaseContent && (precheckQuery.isLoading || precheckQuery.isFetching)
const releaseContentCheckFailed = canCheckReleaseContent && precheckQuery.isError
const releaseContentReady = canCheckReleaseContent
&& precheckQuery.isSuccess
&& !isCheckingReleaseContent
&& !releaseContentCheckFailed
&& Boolean(precheckQuery.data?.canCreate)
return {
isCheckingReleaseContent,
matchedRelease,
releaseContentCheckFailed,
releaseContentReady,
unsupportedNodes,
}
}
export type ReleaseContentCheck = ReturnType<typeof useReleaseContentCheck>
export function useCreateReleaseSourceSelection(formValues: CreateReleaseFormValues) {
const dslState = useAtomValue(createReleaseDslStateAtom)
return createReleaseSourceSelection(formValues, dslState)
}
export function createReleaseReadiness({
formValues,
isSubmitting,
releaseContent,
}: {
formValues: CreateReleaseFormValues
isSubmitting: boolean
releaseContent: ReleaseContentCheck
}) {
const canCreate = Boolean(
formValues.releaseName.trim()
&& releaseContent.releaseContentReady
&& !isSubmitting,
)
return {
canCreate,
isCheckingReleaseContent: releaseContent.isCheckingReleaseContent,
}
}

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "الوصف",
"versions.releaseDescriptionPlaceholder": "صف هذا الإصدار",
"versions.releaseHistory": "سجل الإصدارات",
"versions.releaseNameConflict": "يوجد بالفعل إصدار بهذا الاسم. اختر اسمًا آخر.",
"versions.releaseNameLabel": "اسم الإصدار",
"versions.releaseNamePlaceholder": "اسم الإصدار",
"versions.releaseNameRequired": "أدخل اسم الإصدار.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Beschreibung",
"versions.releaseDescriptionPlaceholder": "Beschreiben Sie dieses Release",
"versions.releaseHistory": "Release-Historie",
"versions.releaseNameConflict": "Ein Release mit diesem Namen existiert bereits. Wählen Sie einen anderen Namen.",
"versions.releaseNameLabel": "Release-Name",
"versions.releaseNamePlaceholder": "Release-Name",
"versions.releaseNameRequired": "Geben Sie einen Release-Namen ein.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Description",
"versions.releaseDescriptionPlaceholder": "Describe this release",
"versions.releaseHistory": "Release history",
"versions.releaseNameConflict": "A release with this name already exists. Choose another name.",
"versions.releaseNameLabel": "Release Name",
"versions.releaseNamePlaceholder": "Release Name",
"versions.releaseNameRequired": "Enter a release name.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Descripción",
"versions.releaseDescriptionPlaceholder": "Describe esta versión",
"versions.releaseHistory": "Historial de versiones",
"versions.releaseNameConflict": "Ya existe una versión con este nombre. Elige otro nombre.",
"versions.releaseNameLabel": "Nombre de la versión",
"versions.releaseNamePlaceholder": "Nombre de la versión",
"versions.releaseNameRequired": "Introduce un nombre de versión.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "توضیحات",
"versions.releaseDescriptionPlaceholder": "این نسخه را توصیف کنید",
"versions.releaseHistory": "تاریخچه نسخه",
"versions.releaseNameConflict": "نسخه‌ای با این نام از قبل وجود دارد. نام دیگری انتخاب کنید.",
"versions.releaseNameLabel": "نام نسخه",
"versions.releaseNamePlaceholder": "نام نسخه",
"versions.releaseNameRequired": "یک نام نسخه وارد کنید.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Description",
"versions.releaseDescriptionPlaceholder": "Décrivez cette version",
"versions.releaseHistory": "Historique des versions",
"versions.releaseNameConflict": "Une version portant ce nom existe déjà. Choisissez un autre nom.",
"versions.releaseNameLabel": "Nom de la version",
"versions.releaseNamePlaceholder": "Nom de la version",
"versions.releaseNameRequired": "Entrez un nom de version.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "विवरण",
"versions.releaseDescriptionPlaceholder": "इस रिलीज़ का वर्णन करें",
"versions.releaseHistory": "रिलीज़ इतिहास",
"versions.releaseNameConflict": "इस नाम वाली रिलीज़ पहले से मौजूद है। कोई दूसरा नाम चुनें।",
"versions.releaseNameLabel": "रिलीज़ नाम",
"versions.releaseNamePlaceholder": "रिलीज़ नाम",
"versions.releaseNameRequired": "एक रिलीज़ नाम दर्ज करें।",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Deskripsi",
"versions.releaseDescriptionPlaceholder": "Jelaskan rilis ini",
"versions.releaseHistory": "Riwayat rilis",
"versions.releaseNameConflict": "Rilis dengan nama ini sudah ada. Pilih nama lain.",
"versions.releaseNameLabel": "Nama Rilis",
"versions.releaseNamePlaceholder": "Nama Rilis",
"versions.releaseNameRequired": "Masukkan nama rilis.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Descrizione",
"versions.releaseDescriptionPlaceholder": "Descrivi questa release",
"versions.releaseHistory": "Cronologia release",
"versions.releaseNameConflict": "Esiste già una release con questo nome. Scegli un altro nome.",
"versions.releaseNameLabel": "Nome release",
"versions.releaseNamePlaceholder": "Nome release",
"versions.releaseNameRequired": "Inserisci un nome per la release.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "説明",
"versions.releaseDescriptionPlaceholder": "このリリースを説明してください",
"versions.releaseHistory": "リリース履歴",
"versions.releaseNameConflict": "このリリース名は既に存在します。別の名前を選択してください。",
"versions.releaseNameLabel": "リリース名",
"versions.releaseNamePlaceholder": "リリース名",
"versions.releaseNameRequired": "リリース名を入力してください。",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "설명",
"versions.releaseDescriptionPlaceholder": "이 릴리스를 설명하세요",
"versions.releaseHistory": "릴리스 기록",
"versions.releaseNameConflict": "이 릴리스 이름은 이미 존재합니다. 다른 이름을 선택하세요.",
"versions.releaseNameLabel": "릴리스 이름",
"versions.releaseNamePlaceholder": "릴리스 이름",
"versions.releaseNameRequired": "릴리스 이름을 입력하세요.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Beschrijving",
"versions.releaseDescriptionPlaceholder": "Beschrijf deze release",
"versions.releaseHistory": "Releasegeschiedenis",
"versions.releaseNameConflict": "Er bestaat al een release met deze naam. Kies een andere naam.",
"versions.releaseNameLabel": "Releasenaam",
"versions.releaseNamePlaceholder": "Releasenaam",
"versions.releaseNameRequired": "Voer een releasenaam in.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Opis",
"versions.releaseDescriptionPlaceholder": "Opisz to wydanie",
"versions.releaseHistory": "Historia wydań",
"versions.releaseNameConflict": "Wydanie o tej nazwie już istnieje. Wybierz inną nazwę.",
"versions.releaseNameLabel": "Nazwa Wydania",
"versions.releaseNamePlaceholder": "Nazwa Wydania",
"versions.releaseNameRequired": "Wprowadź nazwę wydania.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Descrição",
"versions.releaseDescriptionPlaceholder": "Descreva esta versão",
"versions.releaseHistory": "Histórico de versões",
"versions.releaseNameConflict": "Já existe uma versão com este nome. Escolha outro nome.",
"versions.releaseNameLabel": "Nome da versão",
"versions.releaseNamePlaceholder": "Nome da versão",
"versions.releaseNameRequired": "Insira um nome de versão.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Descriere",
"versions.releaseDescriptionPlaceholder": "Descrie această versiune",
"versions.releaseHistory": "Istoric versiuni",
"versions.releaseNameConflict": "Există deja o versiune cu acest nume. Alege alt nume.",
"versions.releaseNameLabel": "Nume versiune",
"versions.releaseNamePlaceholder": "Nume versiune",
"versions.releaseNameRequired": "Introdu un nume pentru versiune.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Описание",
"versions.releaseDescriptionPlaceholder": "Опишите этот Релиз",
"versions.releaseHistory": "История Релизов",
"versions.releaseNameConflict": "Релиз с таким именем уже существует. Выберите другое имя.",
"versions.releaseNameLabel": "Имя Релиза",
"versions.releaseNamePlaceholder": "Имя Релиза",
"versions.releaseNameRequired": "Введите имя Релиза.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Opis",
"versions.releaseDescriptionPlaceholder": "Opišite to izdajo",
"versions.releaseHistory": "Zgodovina izdaj",
"versions.releaseNameConflict": "Izdaja s tem imenom že obstaja. Izberite drugo ime.",
"versions.releaseNameLabel": "Ime izdaje",
"versions.releaseNamePlaceholder": "Ime izdaje",
"versions.releaseNameRequired": "Vnesite ime izdaje.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "คำอธิบาย",
"versions.releaseDescriptionPlaceholder": "อธิบายรีลีสนี้",
"versions.releaseHistory": "ประวัติรีลีส",
"versions.releaseNameConflict": "มีรีลีสชื่อนี้อยู่แล้ว โปรดเลือกชื่ออื่น",
"versions.releaseNameLabel": "ชื่อรีลีส",
"versions.releaseNamePlaceholder": "ชื่อรีลีส",
"versions.releaseNameRequired": "ป้อนชื่อรีลีส",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Açıklama",
"versions.releaseDescriptionPlaceholder": "Bu sürümü açıklayın",
"versions.releaseHistory": "Sürüm geçmişi",
"versions.releaseNameConflict": "Bu ada sahip bir sürüm zaten var. Başka bir ad seçin.",
"versions.releaseNameLabel": "Sürüm Adı",
"versions.releaseNamePlaceholder": "Sürüm Adı",
"versions.releaseNameRequired": "Bir sürüm adı girin.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Опис",
"versions.releaseDescriptionPlaceholder": "Опишіть цей реліз",
"versions.releaseHistory": "Історія релізів",
"versions.releaseNameConflict": "Реліз із такою назвою вже існує. Виберіть іншу назву.",
"versions.releaseNameLabel": "Назва Релізу",
"versions.releaseNamePlaceholder": "Назва Релізу",
"versions.releaseNameRequired": "Введіть назву релізу.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "Mô tả",
"versions.releaseDescriptionPlaceholder": "Mô tả bản phát hành này",
"versions.releaseHistory": "Lịch sử bản phát hành",
"versions.releaseNameConflict": "Tên bản phát hành này đã tồn tại. Hãy chọn tên khác.",
"versions.releaseNameLabel": "Tên bản phát hành",
"versions.releaseNamePlaceholder": "Tên bản phát hành",
"versions.releaseNameRequired": "Vui lòng nhập tên bản phát hành.",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "描述",
"versions.releaseDescriptionPlaceholder": "描述这个版本",
"versions.releaseHistory": "版本历史",
"versions.releaseNameConflict": "此版本名称已存在,请选择其他名称。",
"versions.releaseNameLabel": "版本名称",
"versions.releaseNamePlaceholder": "版本名称",
"versions.releaseNameRequired": "请输入版本名称。",

View File

@ -613,6 +613,7 @@
"versions.releaseDescriptionLabel": "描述",
"versions.releaseDescriptionPlaceholder": "描述此版本",
"versions.releaseHistory": "版本歷史",
"versions.releaseNameConflict": "此版本名稱已存在,請選擇其他名稱。",
"versions.releaseNameLabel": "版本名稱",
"versions.releaseNamePlaceholder": "版本名稱",
"versions.releaseNameRequired": "請輸入版本名稱。",