Skip to content
Draft
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
27 changes: 27 additions & 0 deletions src/ProjectMgmt/Domain/Service/ProjectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ public function create(
return $project;
}

public function cloneProject(Project $sourceProject, string $name): Project
{
return $this->create(
$sourceProject->getOrganizationId(),
$name,
$sourceProject->getGitUrl(),
$sourceProject->getGithubToken(),
$sourceProject->getContentEditingLlmModelProvider(),
$sourceProject->getContentEditingLlmModelProviderApiKey(),
$sourceProject->getProjectType(),
$sourceProject->getAgentImage(),
$sourceProject->getAgentBackgroundInstructions(),
$sourceProject->getAgentStepInstructions(),
$sourceProject->getAgentOutputInstructions(),
$sourceProject->getRemoteContentAssetsManifestUrls(),
$sourceProject->getS3BucketName(),
$sourceProject->getS3Region(),
$sourceProject->getS3AccessKeyId(),
$sourceProject->getS3SecretAccessKey(),
$sourceProject->getS3IamRoleArn(),
$sourceProject->getS3KeyPrefix(),
$sourceProject->isKeysVisible(),
$sourceProject->getPhotoBuilderLlmModelProvider(),
$sourceProject->getPhotoBuilderLlmModelProviderApiKey(),
);
}

/**
* @param list<string>|null $remoteContentAssetsManifestUrls
*/
Expand Down
76 changes: 76 additions & 0 deletions src/ProjectMgmt/Presentation/Controller/ProjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface;
use App\LlmContentEditor\Facade\Enum\LlmModelProvider;
use App\LlmContentEditor\Facade\LlmContentEditorFacadeInterface;
use App\ProjectMgmt\Domain\Entity\Project;
use App\ProjectMgmt\Domain\Service\ProjectService;
use App\ProjectMgmt\Facade\Dto\ExistingLlmApiKeyDto;
use App\ProjectMgmt\Facade\Enum\ProjectType;
Expand Down Expand Up @@ -233,6 +234,65 @@ public function create(Request $request): Response
return $this->redirectToRoute('project_mgmt.presentation.list');
}

#[Route(
path: '/projects/{id}/clone',
name: 'project_mgmt.presentation.clone',
methods: [Request::METHOD_GET],
requirements: ['id' => '[a-f0-9-]{36}']
)]
public function showCloneForm(string $id): Response
{
$organizationId = $this->getActiveOrganizationId();
if ($organizationId === null) {
$this->addFlash('error', $this->translator->trans('flash.error.no_organization'));

return $this->redirectToRoute('account.presentation.dashboard');
}

$sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId);

return $this->render('@project_mgmt.presentation/project_clone_form.twig', [
'sourceProject' => $sourceProject,
'suggestedProjectName' => $this->buildCloneProjectName($sourceProject->getName()),
]);
}

#[Route(
path: '/projects/{id}/clone',
name: 'project_mgmt.presentation.clone_submit',
methods: [Request::METHOD_POST],
requirements: ['id' => '[a-f0-9-]{36}']
)]
public function cloneSubmit(string $id, Request $request): Response
{
$organizationId = $this->getActiveOrganizationId();
if ($organizationId === null) {
$this->addFlash('error', $this->translator->trans('flash.error.no_organization'));

return $this->redirectToRoute('account.presentation.dashboard');
}

$sourceProject = $this->getAccessibleProjectOrThrowNotFound($id, $organizationId);

if (!$this->isCsrfTokenValid('project_clone_' . $id, $request->request->getString('_csrf_token'))) {
$this->addFlash('error', $this->translator->trans('flash.error.invalid_csrf'));

return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]);
}

$name = trim($request->request->getString('name'));
if ($name === '') {
$this->addFlash('error', $this->translator->trans('flash.error.project_clone_name_required'));

return $this->redirectToRoute('project_mgmt.presentation.clone', ['id' => $id]);
}

$clonedProject = $this->projectService->cloneProject($sourceProject, $name);
$this->addFlash('success', $this->translator->trans('flash.success.project_cloned', ['%name%' => $clonedProject->getName()]));

return $this->redirectToRoute('project_mgmt.presentation.list');
}

#[Route(
path: '/projects/{id}/edit',
name: 'project_mgmt.presentation.edit',
Expand Down Expand Up @@ -687,6 +747,22 @@ private function nullIfEmpty(string $value): ?string
return $value === '' ? null : $value;
}

private function buildCloneProjectName(string $sourceProjectName): string
{
return $this->translator->trans('project.clone.default_name', ['%name%' => $sourceProjectName]);
}

private function getAccessibleProjectOrThrowNotFound(string $projectId, string $organizationId): Project
{
$project = $this->projectService->findById($projectId);

if ($project === null || $project->isDeleted() || $project->getOrganizationId() !== $organizationId) {
throw $this->createNotFoundException('Project not found.');
}

return $project;
}

/**
* Parse remote content assets manifest URLs from request (textarea, one URL per line).
* Returns only valid http/https URLs; invalid lines are skipped.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{% extends '@common.presentation/base_appshell.html.twig' %}

{% block title %}{{ 'project.clone.title'|trans }}{% endblock %}

{% block content %}
<div class="max-w-2xl mx-auto space-y-6" data-test-id="project-clone-page">
<div>
<h1 class="text-2xl font-semibold text-dark-900 dark:text-dark-100">{{ 'project.clone.heading'|trans }}</h1>
<p class="text-dark-600 dark:text-dark-400 mt-1">
{{ 'project.clone.description'|trans({'%name%': sourceProject.name}) }}
</p>
</div>

{% for message in app.flashes('error') %}
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-700 dark:text-red-400">{{ message }}</p>
</div>
{% endfor %}

<form method="post"
action="{{ path('project_mgmt.presentation.clone_submit', { id: sourceProject.id }) }}"
data-test-id="project-clone-form"
class="space-y-6 bg-white dark:bg-dark-800 rounded-lg border border-dark-200 dark:border-dark-700 p-6">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('project_clone_' ~ sourceProject.id) }}">

<div>
<label for="name" class="block text-sm font-medium text-dark-700 dark:text-dark-300 mb-1">
{{ 'project.clone.project_name'|trans }}
</label>
<input type="text"
name="name"
id="name"
data-test-id="project-clone-name"
value="{{ suggestedProjectName }}"
required
placeholder="{{ 'project.clone.project_name_placeholder'|trans }}"
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">
</div>

<div class="rounded-lg border border-dark-200 dark:border-dark-700 bg-dark-50 dark:bg-dark-900 p-4">
<p class="text-sm text-dark-700 dark:text-dark-300">
<span class="font-medium">{{ 'project.clone.source_project'|trans }}</span>
{{ sourceProject.name }}
</p>
<p class="mt-1 text-xs text-dark-500 dark:text-dark-400 font-mono break-all">
{{ sourceProject.gitUrl }}
</p>
</div>

<div class="flex items-center justify-end gap-3 pt-4 border-t border-dark-200 dark:border-dark-700">
<a href="{{ path('project_mgmt.presentation.list') }}"
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">
{{ 'common.cancel'|trans }}
</a>
<button type="submit"
data-test-id="project-clone-submit"
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">
{{ 'project.clone.submit'|trans }}
</button>
</div>
</form>
</div>
{% endblock content %}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@
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">
{{ 'project.list.edit_details'|trans }}
</a>
<a href="{{ path('project_mgmt.presentation.clone', { id: item.project.id }) }}"
data-test-class="project-list-clone-link"
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">
{{ 'project.list.clone_project'|trans }}
</a>
{% if item.workspace %}
<form action="{{ path('project_mgmt.presentation.reset_workspace', { id: item.project.id }) }}"
method="post"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,8 +567,8 @@ export default class extends Controller {

private formatUploadPartialFailureLabel(errorCount: number, total: number): string {
return this.uploadPartialFailureLabelValue
.replaceAll("%errorCount%", String(errorCount))
.replaceAll("%total%", String(total));
.replace(/%errorCount%/g, String(errorCount))
.replace(/%total%/g, String(total));
}

private getUploadErrorFallbackLabel(): string {
Expand Down
Loading