Skip to content

Commit 965ce64

Browse files
Implement project cloning flow with tests and UI
Co-authored-by: Manuel Kießling <manuel@kiessling.net>
1 parent 49fbee2 commit 965ce64

File tree

9 files changed

+541
-2
lines changed

9 files changed

+541
-2
lines changed

src/ProjectMgmt/Domain/Service/ProjectService.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,33 @@ public function create(
7979
return $project;
8080
}
8181

82+
public function cloneProject(Project $sourceProject, string $name): Project
83+
{
84+
return $this->create(
85+
$sourceProject->getOrganizationId(),
86+
$name,
87+
$sourceProject->getGitUrl(),
88+
$sourceProject->getGithubToken(),
89+
$sourceProject->getContentEditingLlmModelProvider(),
90+
$sourceProject->getContentEditingLlmModelProviderApiKey(),
91+
$sourceProject->getProjectType(),
92+
$sourceProject->getAgentImage(),
93+
$sourceProject->getAgentBackgroundInstructions(),
94+
$sourceProject->getAgentStepInstructions(),
95+
$sourceProject->getAgentOutputInstructions(),
96+
$sourceProject->getRemoteContentAssetsManifestUrls(),
97+
$sourceProject->getS3BucketName(),
98+
$sourceProject->getS3Region(),
99+
$sourceProject->getS3AccessKeyId(),
100+
$sourceProject->getS3SecretAccessKey(),
101+
$sourceProject->getS3IamRoleArn(),
102+
$sourceProject->getS3KeyPrefix(),
103+
$sourceProject->isKeysVisible(),
104+
$sourceProject->getPhotoBuilderLlmModelProvider(),
105+
$sourceProject->getPhotoBuilderLlmModelProviderApiKey(),
106+
);
107+
}
108+
82109
/**
83110
* @param list<string>|null $remoteContentAssetsManifestUrls
84111
*/

src/ProjectMgmt/Presentation/Controller/ProjectController.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface;
99
use App\LlmContentEditor\Facade\Enum\LlmModelProvider;
1010
use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface;
11+
use App\ProjectMgmt\Domain\Entity\Project;
1112
use App\ProjectMgmt\Domain\Service\ProjectService;
1213
use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto;
1314
use App\ProjectMgmt\Facade\Enum\ProjectType;
@@ -233,6 +234,65 @@ public function create(Request $request): Response
233234
return $this->redirectToRoute('project_mgmt.presentation.list');
234235
}
235236

237+
#[Route(
238+
path: '/projects/{id}/clone',
239+
name: 'project_mgmt.presentation.clone',
240+
methods: [Request::METHOD_GET],
241+
requirements: ['id' => '[a-f0-9-]{36}']
242+
)]
243+
public function showCloneForm(string $id): Response
244+
{
245+
$organizationId = $this->getActiveOrganizationId();
246+
if ($organizationId === null) {
247+
$this->addFlash('error', $this->translator->trans('flash.error.no_organization'));
248+
249+
return $this->redirectToRoute('account.presentation.dashboard');
250+
}
251+
252+
$sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId);
253+
254+
return $this->render('@project_mgmt.presentation/project_clone_form.twig', [
255+
'sourceProject' => $sourceProject,
256+
'suggestedProjectName' => $this->buildCloneProjectName($sourceProject->getName()),
257+
]);
258+
}
259+
260+
#[Route(
261+
path: '/projects/{id}/clone',
262+
name: 'project_mgmt.presentation.clone_submit',
263+
methods: [Request::METHOD_POST],
264+
requirements: ['id' => '[a-f0-9-]{36}']
265+
)]
266+
public function cloneSubmit(string $id, Request $request): Response
267+
{
268+
$organizationId = $this->getActiveOrganizationId();
269+
if ($organizationId === null) {
270+
$this->addFlash('error', $this->translator->trans('flash.error.no_organization'));
271+
272+
return $this->redirectToRoute('account.presentation.dashboard');
273+
}
274+
275+
$sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId);
276+
277+
if (!$this->isCsrfTokenValid('project_clone_' . $id, $request->request->getString('_csrf_token'))) {
278+
$this->addFlash('error', $this->translator->trans('flash.error.invalid_csrf'));
279+
280+
return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]);
281+
}
282+
283+
$name = trim($request->request->getString('name'));
284+
if ($name === '') {
285+
$this->addFlash('error', $this->translator->trans('flash.error.project_clone_name_required'));
286+
287+
return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]);
288+
}
289+
290+
$clonedProject = $this->projectService->cloneProject($sourceProject, $name);
291+
$this->addFlash('success', $this->translator->trans('flash.success.project_cloned', ['%name%' => $clonedProject->getName()]));
292+
293+
return $this->redirectToRoute('project_mgmt.presentation.list');
294+
}
295+
236296
#[Route(
237297
path: '/projects/{id}/edit',
238298
name: 'project_mgmt.presentation.edit',
@@ -687,6 +747,22 @@ private function nullIfEmpty(string $value): ?string
687747
return $value === '' ? null : $value;
688748
}
689749

750+
private function buildCloneProjectName(string $sourceProjectName): string
751+
{
752+
return $this->translator->trans('project.clone.default_name', ['%name%' => $sourceProjectName]);
753+
}
754+
755+
private function getAccessibleProjectOrThrowNotFound(string $projectId, string $organizationId): Project
756+
{
757+
$project = $this->projectService->findById($projectId);
758+
759+
if ($project === null || $project->isDeleted() || $project->getOrganizationId() !== $organizationId) {
760+
throw $this->createNotFoundException('Project not found.');
761+
}
762+
763+
return $project;
764+
}
765+
690766
/**
691767
* Parse remote content assets manifest URLs from request (textarea, one URL per line).
692768
* Returns only valid http/https URLs; invalid lines are skipped.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{% extends '@common.presentation/base_appshell.html.twig' %}
2+
3+
{% block title %}{{ 'project.clone.title'|trans }}{% endblock %}
4+
5+
{% block content %}
6+
<div class="max-w-2xl mx-auto space-y-6" data-test-id="project-clone-page">
7+
<div>
8+
<h1 class="text-2xl font-semibold text-dark-900 dark:text-dark-100">{{ 'project.clone.heading'|trans }}</h1>
9+
<p class="text-dark-600 dark:text-dark-400 mt-1">
10+
{{ 'project.clone.description'|trans({'%name%': sourceProject.name}) }}
11+
</p>
12+
</div>
13+
14+
{% for message in app.flashes('error') %}
15+
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
16+
<p class="text-sm text-red-700 dark:text-red-400">{{ message }}</p>
17+
</div>
18+
{% endfor %}
19+
20+
<form method="post"
21+
action="{{ path('project_mgmt.presentation.clone_submit', { id: sourceProject.id }) }}"
22+
data-test-id="project-clone-form"
23+
class="space-y-6 bg-white dark:bg-dark-800 rounded-lg border border-dark-200 dark:border-dark-700 p-6">
24+
<input type="hidden" name="_csrf_token" value="{{ csrf_token('project_clone_' ~ sourceProject.id) }}">
25+
26+
<div>
27+
<label for="name" class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-1">
28+
{{ 'project.clone.project_name'|trans }}
29+
</label>
30+
<input type="text"
31+
name="name"
32+
id="name"
33+
data-test-id="project-clone-name"
34+
value="{{ suggestedProjectName }}"
35+
required
36+
placeholder="{{ 'project.clone.project_name_placeholder'|trans }}"
37+
class="w-full px-3 py-2 border border-dark-300 dark:border-dark-600 rounded-md bg-white dark:bg-dark-800 text-dark-900 dark:text-dark-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
38+
</div>
39+
40+
<div class="rounded-lg border border-dark-200 dark:border-dark-700 bg-dark-50 dark:bg-dark-900 p-4">
41+
<p class="text-sm text-dark-700 dark:text-dark-300">
42+
<span class="font-medium">{{ 'project.clone.source_project'|trans }}</span>
43+
{{ sourceProject.name }}
44+
</p>
45+
<p class="mt-1 text-xs text-dark-500 dark:text-dark-400 font-mono break-all">
46+
{{ sourceProject.gitUrl }}
47+
</p>
48+
</div>
49+
50+
<div class="flex items-center justify-end gap-3 pt-4 border-t border-dark-200 dark:border-dark-700">
51+
<a href="{{ path('project_mgmt.presentation.list') }}"
52+
class="px-4 py-2 rounded-md border border-dark-300 dark:border-dark-600 text-dark-700 dark:text-dark-300 text-sm font-medium hover:bg-dark-100 dark:hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-primary-500">
53+
{{ 'common.cancel'|trans }}
54+
</a>
55+
<button type="submit"
56+
data-test-id="project-clone-submit"
57+
class="px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-900">
58+
{{ 'project.clone.submit'|trans }}
59+
</button>
60+
</div>
61+
</form>
62+
</div>
63+
{% endblock content %}

src/ProjectMgmt/Presentation/Resources/templates/project_list.twig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@
114114
class="inline-flex items-center px-3 py-1.5 rounded-md border border-dark-300 dark:border-dark-600 text-dark-700 dark:text-dark-300 text-sm font-medium hover:bg-dark-100 dark:hover:bg-dark-700">
115115
{{ 'project.list.edit_details'|trans }}
116116
</a>
117+
<a href="{{ path('project_mgmt.presentation.clone', { id: item.project.id }) }}"
118+
data-test-class="project-list-clone-link"
119+
class="inline-flex items-center px-3 py-1.5 rounded-md border border-dark-300 dark:border-dark-600 text-dark-700 dark:text-dark-300 text-sm font-medium hover:bg-dark-100 dark:hover:bg-dark-700">
120+
{{ 'project.list.clone_project'|trans }}
121+
</a>
117122
{% if item.workspace %}
118123
<form action="{{ path('project_mgmt.presentation.reset_workspace', { id: item.project.id }) }}"
119124
method="post"

src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,8 +567,8 @@ export default class extends Controller {
567567

568568
private formatUploadPartialFailureLabel(errorCount: number, total: number): string {
569569
return this.uploadPartialFailureLabelValue
570-
.replaceAll("%errorCount%", String(errorCount))
571-
.replaceAll("%total%", String(total));
570+
.replace(/%errorCount%/g, String(errorCount))
571+
.replace(/%total%/g, String(total));
572572
}
573573

574574
private getUploadErrorFallbackLabel(): string {

0 commit comments

Comments
 (0)