diff --git a/api/src/Entity/Activity.php b/api/src/Entity/Activity.php
index 07871f521c..fa7a263738 100644
--- a/api/src/Entity/Activity.php
+++ b/api/src/Entity/Activity.php
@@ -118,7 +118,7 @@ class Activity extends BaseEntity implements BelongsToCampInterface {
public ?Category $category = null;
/**
- * Copy Contents from this Source-Activity.
+ * Copy contents from this source activity.
*/
#[ApiProperty(example: '/activities/1a2b3c4d')]
#[Groups(['create'])]
diff --git a/api/src/Entity/Category.php b/api/src/Entity/Category.php
index c5ac7b2a4e..d342c4c381 100644
--- a/api/src/Entity/Category.php
+++ b/api/src/Entity/Category.php
@@ -107,6 +107,13 @@ class Category extends BaseEntity implements BelongsToCampInterface, CopyFromPro
#[ORM\OneToMany(targetEntity: Activity::class, mappedBy: 'category', orphanRemoval: true)]
public Collection $activities;
+ /**
+ * Copy contents from this source category or activity.
+ */
+ #[ApiProperty(example: '/categories/1a2b3c4d')]
+ #[Groups(['create'])]
+ public null|Activity|Category $copyCategorySource;
+
/**
* The id of the category that was used as a template for creating this category. Internal for now, is
* not published through the API.
diff --git a/api/src/State/CategoryCreateProcessor.php b/api/src/State/CategoryCreateProcessor.php
index 83162985a4..305b7422ba 100644
--- a/api/src/State/CategoryCreateProcessor.php
+++ b/api/src/State/CategoryCreateProcessor.php
@@ -8,6 +8,7 @@
use App\Entity\ContentNode\ColumnLayout;
use App\Entity\ContentType;
use App\State\Util\AbstractPersistProcessor;
+use App\Util\EntityMap;
use Doctrine\ORM\EntityManagerInterface;
/**
@@ -35,6 +36,12 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],
$rootContentNode->data = ['columns' => [['slot' => '1', 'width' => 12]]];
$data->setRootContentNode($rootContentNode);
+ if (isset($data->copyCategorySource)) {
+ // CopyActivity Source is set -> copy it's content (rootContentNode)
+ $entityMap = new EntityMap();
+ $rootContentNode->copyFromPrototype($data->copyCategorySource->getRootContentNode(), $entityMap);
+ }
+
return $data;
}
}
diff --git a/api/tests/Api/Categories/CreateCategoryTest.php b/api/tests/Api/Categories/CreateCategoryTest.php
index 5433282fb1..14a0b76f42 100644
--- a/api/tests/Api/Categories/CreateCategoryTest.php
+++ b/api/tests/Api/Categories/CreateCategoryTest.php
@@ -99,10 +99,10 @@ public function testCreateCategoryCreatesNewColumnLayoutAsRootContentNode() {
$this->assertResponseStatusCodeSame(201);
$newestColumnLayout = $this->getEntityManager()->getRepository(ContentNode::class)
- ->findBy(['contentType' => static::$fixtures['contentTypeColumnLayout']], ['createTime' => 'DESC'])[0]
+ ->findBy(['contentType' => static::$fixtures['contentTypeColumnLayout'], 'instanceName' => null], ['createTime' => 'DESC'], 1)[0]
;
$this->assertJsonContains(['_links' => [
- 'rootContentNode' => ['href' => '/content_node/column_layouts/'.$newestColumnLayout->getId()],
+ 'rootContentNode' => ['href' => $this->getIriFor($newestColumnLayout)],
]]);
}
@@ -456,6 +456,102 @@ public function testCreateCategoryValidatesInvalidNumberingStyle() {
]);
}
+ public function testCreateCategoryFromCopySourceValidatesAccess() {
+ static::createClientWithCredentials(['email' => static::$fixtures['user8memberOnlyInCamp2']->getEmail()])->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp2'),
+ 'copyCategorySource' => $this->getIriFor('category1'),
+ ]
+ )]
+ );
+
+ // No Access on category1 -> BadRequest
+ $this->assertResponseStatusCodeSame(400);
+ }
+
+ public function testCreateCategoryFromCopySourceWithinSameCamp() {
+ static::createClientWithCredentials()->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp1'),
+ 'copyCategorySource' => $this->getIriFor('category1'),
+ ],
+ )]
+ );
+
+ // Category created
+ $this->assertResponseStatusCodeSame(201);
+ }
+
+ public function testCreateCategoryFromCopySourceAcrossCamp() {
+ static::createClientWithCredentials()->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp2'),
+ 'copyCategorySource' => $this->getIriFor('category1'),
+ ],
+ )]
+ );
+
+ // Category created
+ $this->assertResponseStatusCodeSame(201);
+ }
+
+ public function testCreateCategoryFromCopySourceActivityValidatesAccess() {
+ static::createClientWithCredentials(['email' => static::$fixtures['user8memberOnlyInCamp2']->getEmail()])->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp2'),
+ 'copyCategorySource' => $this->getIriFor('activity1'),
+ ]
+ )]
+ );
+
+ // No Access on activity1 -> BadRequest
+ $this->assertResponseStatusCodeSame(400);
+ }
+
+ public function testCreateCategoryFromCopySourceActivityWithinSameCamp() {
+ static::createClientWithCredentials()->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp1'),
+ 'copyCategorySource' => $this->getIriFor('activity1'),
+ ],
+ )]
+ );
+
+ // Category created
+ $this->assertResponseStatusCodeSame(201);
+ }
+
+ public function testCreateCategoryFromCopySourceActivityAcrossCamp() {
+ static::createClientWithCredentials()->request(
+ 'POST',
+ '/categories',
+ ['json' => $this->getExampleWritePayload(
+ [
+ 'camp' => $this->getIriFor('camp2'),
+ 'copyCategorySource' => $this->getIriFor('activity1'),
+ ],
+ )]
+ );
+
+ // Category created
+ $this->assertResponseStatusCodeSame(201);
+ }
+
/**
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
@@ -488,6 +584,7 @@ public function getExampleWritePayload($attributes = [], $except = []) {
Category::class,
Post::class,
array_merge([
+ 'copyCategorySource' => null,
'camp' => $this->getIriFor('camp1'),
'preferredContentTypes' => [$this->getIriFor('contentTypeSafetyConcept')],
], $attributes),
diff --git a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml
index 60be7ed441..87e6a210a3 100644
--- a/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml
+++ b/api/tests/Api/SnapshotTests/__snapshots__/ResponseSnapshotTest__testOpenApiSpecMatchesSnapshot__1.yml
@@ -362,7 +362,7 @@ components:
format: iri-reference
type: string
copyActivitySource:
- description: 'Copy Contents from this Source-Activity.'
+ description: 'Copy contents from this source activity.'
example: /activities/1a2b3c4d
format: iri-reference
type:
@@ -759,7 +759,7 @@ components:
format: iri-reference
type: string
copyActivitySource:
- description: 'Copy Contents from this Source-Activity.'
+ description: 'Copy contents from this source activity.'
example: /activities/1a2b3c4d
format: iri-reference
type:
@@ -1168,7 +1168,7 @@ components:
format: iri-reference
type: string
copyActivitySource:
- description: 'Copy Contents from this Source-Activity.'
+ description: 'Copy contents from this source activity.'
example: /activities/1a2b3c4d
format: iri-reference
type:
@@ -1607,7 +1607,7 @@ components:
format: iri-reference
type: string
copyActivitySource:
- description: 'Copy Contents from this Source-Activity.'
+ description: 'Copy contents from this source activity.'
example: /activities/1a2b3c4d
format: iri-reference
type:
@@ -7938,6 +7938,13 @@ components:
maxLength: 8
pattern: '^(#[0-9a-zA-Z]{6})$'
type: string
+ copyCategorySource:
+ description: 'Copy contents from this source category or activity.'
+ example: /categories/1a2b3c4d
+ format: iri-reference
+ type:
+ - 'null'
+ - string
name:
description: 'The full name of the category.'
example: Lagersport
@@ -8296,6 +8303,13 @@ components:
maxLength: 8
pattern: '^(#[0-9a-zA-Z]{6})$'
type: string
+ copyCategorySource:
+ description: 'Copy contents from this source category or activity.'
+ example: /categories/1a2b3c4d
+ format: iri-reference
+ type:
+ - 'null'
+ - string
name:
description: 'The full name of the category.'
example: Lagersport
@@ -8690,6 +8704,13 @@ components:
maxLength: 8
pattern: '^(#[0-9a-zA-Z]{6})$'
type: string
+ copyCategorySource:
+ description: 'Copy contents from this source category or activity.'
+ example: /categories/1a2b3c4d
+ format: iri-reference
+ type:
+ - 'null'
+ - string
name:
description: 'The full name of the category.'
example: Lagersport
@@ -9062,6 +9083,13 @@ components:
maxLength: 8
pattern: '^(#[0-9a-zA-Z]{6})$'
type: string
+ copyCategorySource:
+ description: 'Copy contents from this source category or activity.'
+ example: /categories/1a2b3c4d
+ format: iri-reference
+ type:
+ - 'null'
+ - string
name:
description: 'The full name of the category.'
example: Lagersport
diff --git a/frontend/src/components/activity/ScheduleEntry.vue b/frontend/src/components/activity/ScheduleEntry.vue
index 47903df81c..36b8d1a297 100644
--- a/frontend/src/components/activity/ScheduleEntry.vue
+++ b/frontend/src/components/activity/ScheduleEntry.vue
@@ -402,7 +402,7 @@ export default {
async copyUrlToClipboard() {
try {
const res = await navigator.permissions.query({ name: 'clipboard-read' })
- if (res.state == 'prompt') {
+ if (res.state === 'prompt') {
this.$refs.copyInfoDialog.open()
}
} catch {
diff --git a/frontend/src/components/campAdmin/DialogCategoryCreate.vue b/frontend/src/components/campAdmin/DialogCategoryCreate.vue
index e8275f9ccc..ae78f22109 100644
--- a/frontend/src/components/campAdmin/DialogCategoryCreate.vue
+++ b/frontend/src/components/campAdmin/DialogCategoryCreate.vue
@@ -14,7 +14,88 @@
+ {{ $tc('components.category.copyCategoryInfoDialog.description') }} +
++
+ {{ $tc('components.category.copyCategoryInfoDialog.granted') }} +
++ {{ $tc('components.category.copyCategoryInfoDialog.denied') }} +
+