From 7d5b8e566f68dea76e70ca400931e9d83e91f755 Mon Sep 17 00:00:00 2001 From: "Bestony@Homelab" Date: Fri, 17 Jul 2026 00:47:12 +0800 Subject: [PATCH 1/2] feat(frontend): select parent projects for app deployments --- src/api/ApiManager.test.ts | 78 +++++++++ src/api/ApiManager.ts | 34 ++++ src/components/ParentProjectSelector.test.tsx | 56 +++++++ src/components/ParentProjectSelector.tsx | 37 +++++ src/components/ProjectSelector.tsx | 6 +- src/containers/apps/CreateNewApp.test.tsx | 45 ++++++ src/containers/apps/CreateNewApp.tsx | 37 ++--- .../apps/compose/DockerComposeEntry.tsx | 153 +++++++++++++++--- .../variables/OneClickAppConfigPage.tsx | 81 +++++++--- .../variables/OneClickDeploymentPage.tsx | 42 ++--- src/utils/OneClickDeploymentUrl.test.ts | 53 ++++++ src/utils/OneClickDeploymentUrl.ts | 69 ++++++++ src/utils/ProjectName.test.ts | 25 +++ src/utils/ProjectName.ts | 15 ++ 14 files changed, 630 insertions(+), 101 deletions(-) create mode 100644 src/api/ApiManager.test.ts create mode 100644 src/components/ParentProjectSelector.test.tsx create mode 100644 src/components/ParentProjectSelector.tsx create mode 100644 src/containers/apps/CreateNewApp.test.tsx create mode 100644 src/utils/OneClickDeploymentUrl.test.ts create mode 100644 src/utils/OneClickDeploymentUrl.ts create mode 100644 src/utils/ProjectName.test.ts create mode 100644 src/utils/ProjectName.ts 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..9d02b540 --- /dev/null +++ b/src/components/ParentProjectSelector.test.tsx @@ -0,0 +1,56 @@ +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('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..bdd9d77f --- /dev/null +++ b/src/components/ParentProjectSelector.tsx @@ -0,0 +1,37 @@ +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 +} + +export default function ParentProjectSelector( + props: ParentProjectSelectorProps +) { + const projects = props.projects || [] + if (projects.length === 0) { + return <> + } + + return ( +
+
+ {localize('apps.parent_project', 'Parent project')} +
+ { + props.onChange(`${value || ''}`.trim()) + }} + excludeProjectId={props.excludeProjectId || 'NONE'} + /> +
+ ) +} diff --git a/src/components/ProjectSelector.tsx b/src/components/ProjectSelector.tsx index 7f285740..fda183ab 100644 --- a/src/components/ProjectSelector.tsx +++ b/src/components/ProjectSelector.tsx @@ -46,7 +46,7 @@ class ProjectSelector extends React.Component { return true } // eslint-disable-next-line no-loop-func - current = projectsMap[current].parentProjectId + current = projectsMap[current]?.parentProjectId } return false } @@ -85,7 +85,7 @@ class ProjectSelector extends React.Component { ] const handleChange = (value: string) => { - onChange(value ?? '') + onChange(`${value || ''}`.trim()) } return ( @@ -98,7 +98,7 @@ class ProjectSelector extends React.Component { 'Select a parent project' )} dropdownStyle={{ maxHeight: 400, overflow: 'auto' }} - value={selectedProjectId || ''} + value={`${selectedProjectId || ''}`.trim()} onChange={handleChange} treeData={root} /> diff --git a/src/containers/apps/CreateNewApp.test.tsx b/src/containers/apps/CreateNewApp.test.tsx new file mode 100644 index 00000000..d1f1c48d --- /dev/null +++ b/src/containers/apps/CreateNewApp.test.tsx @@ -0,0 +1,45 @@ +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 + ) + }) +}) diff --git a/src/containers/apps/CreateNewApp.tsx b/src/containers/apps/CreateNewApp.tsx index 8fe9cc9f..8bb18b54 100644 --- a/src/containers/apps/CreateNewApp.tsx +++ b/src/containers/apps/CreateNewApp.tsx @@ -2,7 +2,7 @@ import { PlusCircleOutlined, QuestionCircleFilled } from '@ant-design/icons' import { Button, Card, Checkbox, Col, Input, Row, Tooltip } from 'antd' import { Component, Fragment } from 'react' import { connect } from 'react-redux' -import ProjectSelector from '../../components/ProjectSelector' +import ParentProjectSelector from '../../components/ParentProjectSelector' import { IMobileComponent } from '../../models/ContainerProps' import ProjectDefinition from '../../models/ProjectDefinition' import { localize } from '../../utils/Language' @@ -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 } > { @@ -212,31 +212,16 @@ class CreateNewApp extends Component< createProjectInApp() { const self = this - if ((self.props.projects || []).length === 0) { - return undefined - } - return ( -
- {' '} -
- {localize('apps.parent_project', 'Parent project')} -
- { - self.setState({ - selectedProjectId: value, - }) - }} - excludeProjectId={'NONE'} - /> -
+ { + self.setState({ + selectedProjectId: value, + }) + }} + /> ) } } diff --git a/src/containers/apps/compose/DockerComposeEntry.tsx b/src/containers/apps/compose/DockerComposeEntry.tsx index 2663e4f6..948ca78f 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,57 @@ volumes: }} />
+ { + self.setState({ selectedProjectId }) + }} + 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 + ), + }) + }} + /> +
+ )}