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 + ), + }) + }} + /> +
+ )}