diff --git a/src/api/ApiManager.test.ts b/src/api/ApiManager.test.ts
new file mode 100644
index 00000000..af8a2e86
--- /dev/null
+++ b/src/api/ApiManager.test.ts
@@ -0,0 +1,78 @@
+import ApiManager from './ApiManager'
+
+describe('ApiManager.startOneClickAppDeploy', () => {
+ it('sends parent and group project options through the generic API', async () => {
+ const apiManager = Object.create(ApiManager.prototype) as ApiManager
+ const executeGenericApiCommand = jest
+ .fn()
+ .mockResolvedValue({ jobId: 'job-id' })
+ const mutableApiManager = apiManager as any
+ mutableApiManager.executeGenericApiCommand = executeGenericApiCommand
+ const template = { services: { web: {} } }
+ const values = [{ key: 'KEY', value: 'value' }]
+
+ await apiManager.startOneClickAppDeploy(template, values, {
+ parentProjectId: ' parent-id ',
+ projectName: ' compose-stack ',
+ })
+
+ expect(executeGenericApiCommand).toHaveBeenCalledWith(
+ 'POST',
+ '/user/oneclick/deploy',
+ {
+ template,
+ values,
+ parentProjectId: 'parent-id',
+ projectName: 'compose-stack',
+ }
+ )
+ })
+
+ it('sends an explicit root project for old callers', async () => {
+ const apiManager = Object.create(ApiManager.prototype) as ApiManager
+ const executeGenericApiCommand = jest.fn().mockResolvedValue({
+ jobId: 'job-id',
+ })
+ const mutableApiManager = apiManager as any
+ mutableApiManager.executeGenericApiCommand = executeGenericApiCommand
+
+ await apiManager.startOneClickAppDeploy({}, [])
+
+ expect(executeGenericApiCommand).toHaveBeenCalledWith(
+ 'POST',
+ '/user/oneclick/deploy',
+ {
+ template: {},
+ values: [],
+ parentProjectId: '',
+ }
+ )
+ })
+
+ it('accepts positional project options for compatible callers', async () => {
+ const apiManager = Object.create(ApiManager.prototype) as ApiManager
+ const executeGenericApiCommand = jest.fn().mockResolvedValue({
+ jobId: 'job-id',
+ })
+ const mutableApiManager = apiManager as any
+ mutableApiManager.executeGenericApiCommand = executeGenericApiCommand
+
+ await apiManager.startOneClickAppDeploy(
+ {},
+ [],
+ 'parent-id',
+ 'compose-stack'
+ )
+
+ expect(executeGenericApiCommand).toHaveBeenCalledWith(
+ 'POST',
+ '/user/oneclick/deploy',
+ {
+ template: {},
+ values: [],
+ parentProjectId: 'parent-id',
+ projectName: 'compose-stack',
+ }
+ )
+ })
+})
diff --git a/src/api/ApiManager.ts b/src/api/ApiManager.ts
index 2235447f..e4c9cfde 100644
--- a/src/api/ApiManager.ts
+++ b/src/api/ApiManager.ts
@@ -8,6 +8,11 @@ const BASE_DOMAIN = process.env.REACT_APP_API_URL
const URL = BASE_DOMAIN
Logger.dev(`API URL: ${URL}`)
+export interface OneClickDeploymentOptions {
+ parentProjectId?: string
+ projectName?: string
+}
+
const authProvider = {
authToken: '' as string,
hadEnteredOtp: false as boolean,
@@ -44,6 +49,35 @@ export default class ApiManager extends CapRoverAPI {
return URL
}
+ startOneClickAppDeploy(
+ template: any,
+ values?: any,
+ optionsOrParentProjectId: OneClickDeploymentOptions | string = {},
+ projectName?: string
+ ): Promise<{ jobId: string }> {
+ const options: OneClickDeploymentOptions =
+ typeof optionsOrParentProjectId === 'string'
+ ? {
+ parentProjectId: optionsOrParentProjectId,
+ projectName,
+ }
+ : optionsOrParentProjectId || {}
+ const requestBody: any = {
+ template,
+ values,
+ parentProjectId: `${options.parentProjectId || ''}`.trim(),
+ }
+
+ if (options.projectName !== undefined) {
+ requestBody.projectName = `${options.projectName || ''}`.trim()
+ }
+ return this.executeGenericApiCommand(
+ 'POST',
+ '/user/oneclick/deploy',
+ requestBody
+ )
+ }
+
static clearAuthKeys() {
authProvider.authToken = ''
StorageHelper.clearAuthKeys()
diff --git a/src/components/ParentProjectSelector.test.tsx b/src/components/ParentProjectSelector.test.tsx
new file mode 100644
index 00000000..68152097
--- /dev/null
+++ b/src/components/ParentProjectSelector.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import ParentProjectSelector from './ParentProjectSelector'
+
+jest.mock('../utils/Language', () => ({
+ localize: (key: string, message: string) => message,
+}))
+
+jest.mock('./ProjectSelector', () => (props: any) => (
+
+ {props.selectedProjectId}
+
+
+
+))
+
+const projects = [
+ {
+ id: 'project-id',
+ name: 'project',
+ description: '',
+ },
+]
+
+describe('ParentProjectSelector', () => {
+ it('stays hidden when no projects exist', () => {
+ const { container } = render(
+
+ )
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('shows the root selector when explicitly requested without projects', () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId('selected-project')).toHaveTextContent('')
+ })
+
+ it('normalizes selection and exposes root through clear', () => {
+ const onChange = jest.fn()
+ render(
+
+ )
+
+ expect(screen.getByTestId('selected-project')).toHaveTextContent(
+ 'project-id'
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'Select' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }))
+ expect(onChange).toHaveBeenNthCalledWith(1, 'project-id')
+ expect(onChange).toHaveBeenNthCalledWith(2, '')
+ })
+})
diff --git a/src/components/ParentProjectSelector.tsx b/src/components/ParentProjectSelector.tsx
new file mode 100644
index 00000000..7f6ac4ac
--- /dev/null
+++ b/src/components/ParentProjectSelector.tsx
@@ -0,0 +1,38 @@
+import { CSSProperties } from 'react'
+import ProjectDefinition from '../models/ProjectDefinition'
+import { localize } from '../utils/Language'
+import ProjectSelector from './ProjectSelector'
+
+interface ParentProjectSelectorProps {
+ projects: ProjectDefinition[]
+ selectedProjectId: string
+ onChange: (projectId: string) => void
+ excludeProjectId?: string
+ style?: CSSProperties
+ hideWhenEmpty?: boolean
+}
+
+export default function ParentProjectSelector(
+ props: ParentProjectSelectorProps
+) {
+ const projects = props.projects || []
+ if (projects.length === 0 && props.hideWhenEmpty !== false) {
+ return <>>
+ }
+
+ return (
+
+
+ {localize('apps.parent_project', 'Parent project')}
+
+
{
+ props.onChange(`${value || ''}`.trim())
+ }}
+ excludeProjectId={props.excludeProjectId || 'NONE'}
+ />
+
+ )
+}
diff --git a/src/containers/apps/CreateNewApp.test.tsx b/src/containers/apps/CreateNewApp.test.tsx
new file mode 100644
index 00000000..6efe2e1f
--- /dev/null
+++ b/src/containers/apps/CreateNewApp.test.tsx
@@ -0,0 +1,51 @@
+import { CreateNewApp } from './CreateNewApp'
+
+jest.mock('../../utils/Language', () => ({
+ localize: (key: string, message: string) => message,
+}))
+
+describe('CreateNewApp project assignment', () => {
+ const createProps = () => ({
+ isMobile: true,
+ projects: [],
+ onCreateNewAppClicked: jest.fn(),
+ onOneClickAppClicked: jest.fn(),
+ onDockerComposeClicked: jest.fn(),
+ })
+
+ it('defaults to root, sends a selection, and supports clearing it', () => {
+ const props = createProps()
+ const component = new CreateNewApp(props as any)
+ const mutableState = component.state as any
+ mutableState.appName = 'test-app'
+
+ component.onCreateNewAppClicked()
+ expect(props.onCreateNewAppClicked).toHaveBeenLastCalledWith(
+ 'test-app',
+ '',
+ false
+ )
+
+ mutableState.selectedProjectId = 'project-id'
+ component.onCreateNewAppClicked()
+ expect(props.onCreateNewAppClicked).toHaveBeenLastCalledWith(
+ 'test-app',
+ 'project-id',
+ false
+ )
+
+ mutableState.selectedProjectId = ''
+ component.onCreateNewAppClicked()
+ expect(props.onCreateNewAppClicked).toHaveBeenLastCalledWith(
+ 'test-app',
+ '',
+ false
+ )
+ })
+
+ it('keeps the apps home selector hidden when no projects exist', () => {
+ const component = new CreateNewApp(createProps() as any)
+
+ expect(component.createProjectInApp()).toBeUndefined()
+ })
+})
diff --git a/src/containers/apps/CreateNewApp.tsx b/src/containers/apps/CreateNewApp.tsx
index 8fe9cc9f..9026aaa4 100644
--- a/src/containers/apps/CreateNewApp.tsx
+++ b/src/containers/apps/CreateNewApp.tsx
@@ -20,7 +20,7 @@ interface MyProps {
projects: ProjectDefinition[]
}
-class CreateNewApp extends Component<
+export class CreateNewApp extends Component<
MyProps & IMobileComponent,
{ appName: string; selectedProjectId: string; hasPersistency: boolean }
> {
diff --git a/src/containers/apps/compose/DockerComposeEntry.tsx b/src/containers/apps/compose/DockerComposeEntry.tsx
index 2663e4f6..cbafb0fb 100644
--- a/src/containers/apps/compose/DockerComposeEntry.tsx
+++ b/src/containers/apps/compose/DockerComposeEntry.tsx
@@ -1,34 +1,69 @@
-import { Button, Card, Col, Row, Typography } from 'antd'
+import { Button, Card, Col, Input, Row, Typography } from 'antd'
import { RouteComponentProps } from 'react-router'
+import ParentProjectSelector from '../../../components/ParentProjectSelector'
+import ProjectDefinition from '../../../models/ProjectDefinition'
import { localize } from '../../../utils/Language'
+import {
+ isProjectNameAllowed,
+ normalizeProjectName,
+} from '../../../utils/ProjectName'
import Toaster from '../../../utils/Toaster'
import Utils from '../../../utils/Utils'
+import { createOneClickDeploymentUrl } from '../../../utils/OneClickDeploymentUrl'
import ApiComponent from '../../global/ApiComponent'
+import CenteredSpinner from '../../global/CenteredSpinner'
+import ErrorRetry from '../../global/ErrorRetry'
import InputJsonifier from '../../global/InputJsonifier'
-import {
- DEPLOYMENT_QUERY_PARAM_APP_NAME,
- DEPLOYMENT_QUERY_PARAM_TEMPLATE,
- DEPLOYMENT_QUERY_PARAM_VALUES_ARRAY,
-} from '../oneclick/variables/OneClickAppConfigPage'
export const TEMPLATE_ONE_CLICK_APP = 'TEMPLATE_ONE_CLICK_APP'
export const ONE_CLICK_APP_STRINGIFIED_KEY = 'oneClickAppStringifiedData'
-export default class OneClickAppSelector extends ApiComponent<
+export function getDockerComposeServiceCount(template: any): number {
+ if (!template?.services || typeof template.services !== 'object') {
+ return 0
+ }
+ return Object.keys(template.services).length
+}
+
+export default class DockerComposeEntry extends ApiComponent<
RouteComponentProps,
{
stringifiedJsonComposeContent: string
+ projects: ProjectDefinition[] | undefined
+ selectedProjectId: string
+ projectName: string
+ loadError: boolean
}
> {
constructor(props: any) {
super(props)
this.state = {
stringifiedJsonComposeContent: '',
+ projects: undefined,
+ selectedProjectId: '',
+ projectName: '',
+ loadError: false,
}
}
componentDidMount() {
- // const self = this
+ const self = this
+ self.apiManager
+ .getAllProjects()
+ .then(function (response) {
+ if (!self.willUnmountSoon) {
+ self.setState({
+ projects: (response.projects ||
+ []) as ProjectDefinition[],
+ })
+ }
+ })
+ .catch(function (error) {
+ Toaster.createCatcher()(error)
+ if (!self.willUnmountSoon) {
+ self.setState({ loadError: true })
+ }
+ })
}
render() {
@@ -39,6 +74,20 @@ export default class OneClickAppSelector extends ApiComponent<
parsedJson = JSON.parse(this.state.stringifiedJsonComposeContent)
} catch (error) {}
+ if (self.state.loadError) {
+ return
+ }
+
+ if (!self.state.projects) {
+ return
+ }
+
+ const serviceCount = getDockerComposeServiceCount(parsedJson)
+ const isMultiService = serviceCount > 1
+ const normalizedProjectName = normalizeProjectName(
+ self.state.projectName
+ )
+
return (
@@ -93,13 +142,58 @@ volumes:
}}
/>
+ {
+ self.setState({ selectedProjectId })
+ }}
+ hideWhenEmpty={false}
+ style={{ marginTop: 24 }}
+ />
+ {isMultiService && (
+
+
+ {localize(
+ 'projects.project_name',
+ 'Project Name'
+ )}
+
+
{
+ self.setState({
+ projectName: event.target.value,
+ })
+ }}
+ onBlur={(event) => {
+ self.setState({
+ projectName:
+ normalizeProjectName(
+ event.target.value
+ ),
+ })
+ }}
+ />
+
+ )}