Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/api/ApiManager.test.ts
Original file line number Diff line number Diff line change
@@ -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',
}
)
})
})
34 changes: 34 additions & 0 deletions src/api/ApiManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions src/components/ParentProjectSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div>
<span data-testid="selected-project">{props.selectedProjectId}</span>
<button onClick={() => props.onChange(' project-id ')}>Select</button>
<button onClick={() => props.onChange('')}>Clear</button>
</div>
))

const projects = [
{
id: 'project-id',
name: 'project',
description: '',
},
]

describe('ParentProjectSelector', () => {
it('stays hidden when no projects exist', () => {
const { container } = render(
<ParentProjectSelector
projects={[]}
selectedProjectId=""
onChange={jest.fn()}
/>
)

expect(container).toBeEmptyDOMElement()
})

it('shows the root selector when explicitly requested without projects', () => {
render(
<ParentProjectSelector
projects={[]}
selectedProjectId=""
onChange={jest.fn()}
hideWhenEmpty={false}
/>
)

expect(screen.getByTestId('selected-project')).toHaveTextContent('')
})

it('normalizes selection and exposes root through clear', () => {
const onChange = jest.fn()
render(
<ParentProjectSelector
projects={projects}
selectedProjectId=" project-id "
onChange={onChange}
/>
)

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, '')
})
})
38 changes: 38 additions & 0 deletions src/components/ParentProjectSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={props.style}>
<div style={{ marginBottom: 5 }}>
{localize('apps.parent_project', 'Parent project')}
</div>
<ProjectSelector
allProjects={projects}
selectedProjectId={`${props.selectedProjectId || ''}`.trim()}
onChange={(value: string) => {
props.onChange(`${value || ''}`.trim())
}}
excludeProjectId={props.excludeProjectId || 'NONE'}
/>
</div>
)
}
51 changes: 51 additions & 0 deletions src/containers/apps/CreateNewApp.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 1 addition & 1 deletion src/containers/apps/CreateNewApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
> {
Expand Down
Loading
Loading