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.campAdmin.dialogCategoryCreate.clipboard') }} + +
+ + + mdi-clipboard-check-outline + + + + + {{ copyCategorySource.title }} + + + {{ copyCategorySource.camp().title }} + + + + + + +
+
+ + + @@ -23,10 +104,17 @@ import { categoryRoute } from '@/router.js' import DialogForm from '@/components/dialog/DialogForm.vue' import DialogBase from '@/components/dialog/DialogBase.vue' import DialogCategoryForm from './DialogCategoryForm.vue' +import PopoverPrompt from '../prompt/PopoverPrompt.vue' +import router from '@/router.js' +import CategoryChip from '../generic/CategoryChip.vue' +import CopyCategoryInfoDialog from '../category/CopyCategoryInfoDialog.vue' export default { name: 'DialogCategoryCreate', components: { + CopyCategoryInfoDialog, + CategoryChip, + PopoverPrompt, DialogCategoryForm, DialogForm, }, @@ -39,23 +127,102 @@ export default { entityProperties: ['camp', 'short', 'name', 'color', 'numberingStyle'], embeddedCollections: ['preferredContentTypes'], entityUri: '/categories', + clipboardPermission: 'unknown', + copyCategorySource: null, + copyCategorySourceUrl: null, + copyCategorySourceUrlLoading: false, + copyCategorySourceUrlShowPopover: false, } }, + computed: { + clipboardAccessDenied() { + return ( + this.clipboardPermission === 'unaccessable' || + this.clipboardPermission === 'denied' + ) + }, + hasCopyCategorySource() { + return this.copyCategorySource != null && this.copyCategorySource._meta.self != null + }, + copyContent: { + get() { + return this.entityData.copyCategorySource != null + }, + set(val) { + if (val) { + this.entityData.copyCategorySource = this.copyCategorySource._meta.self + this.entityData.short = this.copyCategorySourceCategory.short + this.entityData.name = this.copyCategorySourceCategory.name + this.entityData.color = this.copyCategorySourceCategory.color + this.entityData.numberingStyle = this.copyCategorySourceCategory.numberingStyle + } else { + this.entityData.copyCategorySource = null + } + }, + }, + copyCategorySourceCategory() { + if (!this.hasCopyCategorySource) return null + return this.copyCategorySource.short + ? this.copyCategorySource + : this.copyCategorySource.category() + }, + }, watch: { showDialog: function (showDialog) { if (showDialog) { + this.refreshCopyCategorySource() this.setEntityData({ camp: this.camp._meta.self, short: '', name: '', color: '#000000', numberingStyle: '1', + copyCategorySource: null, }) } else { // clear form on exit this.clearEntityData() + this.copyCategorySource = null + this.copyCategorySourceUrl = null } }, + copyCategorySourceUrl: function (url) { + this.copyCategorySourceUrlLoading = true + + this.getCopyCategorySource(url).then( + (categoryOrActivityProxy) => { + if (categoryOrActivityProxy != null) { + categoryOrActivityProxy._meta.load.then( + async (categoryOrActivity) => { + if (!categoryOrActivity.short) { + await categoryOrActivity.category()._meta.load + } + this.copyCategorySource = categoryOrActivity + this.copyContent = true + this.copyCategorySourceUrlLoading = false + }, + () => { + this.copyCategorySourceUrlLoading = false + } + ) + } else { + this.copyCategorySource = null + this.copyContent = false + this.copyCategorySourceUrlLoading = false + } + + // if Paste-Popover is shown, close it now + if (this.copyCategorySourceUrlShowPopover) { + this.$nextTick(() => { + this.copyCategorySourceUrlShowPopover = false + }) + } + }, + () => { + this.copyCategorySourceUrlLoading = false + } + ) + }, }, methods: { async createCategory() { @@ -63,6 +230,51 @@ export default { await this.api.reload(this.camp.categories()) this.$router.push(categoryRoute(this.camp, createdCategory, { new: true })) }, + refreshCopyCategorySource() { + navigator.permissions.query({ name: 'clipboard-read' }).then( + (p) => { + this.clipboardPermission = p.state + this.copyCategorySource = null + + if (p.state === 'granted') { + navigator.clipboard + .readText() + .then(async (url) => { + const copyCategorySource = await this.getCopyCategorySource(url) + this.copyCategorySource = await copyCategorySource?._meta.load + }) + .catch(() => { + this.clipboardPermission = 'unaccessable' + console.warn('clipboard permission not requestable') + }) + } + }, + () => { + this.clipboardPermission = 'unaccessable' + console.warn('clipboard permission not requestable') + } + ) + }, + async getCopyCategorySource(url) { + if (url?.startsWith(window.location.origin)) { + url = url.substring(window.location.origin.length) + const match = router.matcher.match(url) + + if (match.name === 'activity') { + const scheduleEntry = await this.api + .get() + .scheduleEntries({ id: match.params['scheduleEntryId'] }) + return await scheduleEntry.activity() + } else if (match.name === 'admin/activity/category') { + return await this.api.get().categories({ id: match.params['categoryId'] }) + } + } + return null + }, + async clearClipboard() { + await navigator.clipboard.writeText('') + this.refreshCopyCategorySource() + }, }, } diff --git a/frontend/src/components/campAdmin/DialogCategoryForm.vue b/frontend/src/components/campAdmin/DialogCategoryForm.vue index 8d480df706..b82c4847c7 100644 --- a/frontend/src/components/campAdmin/DialogCategoryForm.vue +++ b/frontend/src/components/campAdmin/DialogCategoryForm.vue @@ -1,10 +1,14 @@ + + diff --git a/frontend/src/components/program/DialogActivityCreate.vue b/frontend/src/components/program/DialogActivityCreate.vue index a154044ed2..f9f67373e5 100644 --- a/frontend/src/components/program/DialogActivityCreate.vue +++ b/frontend/src/components/program/DialogActivityCreate.vue @@ -21,7 +21,7 @@ @@ -167,16 +167,16 @@ export default { const sourceCamp = this.copyActivitySource.camp() const sourceCategory = this.copyActivitySource.category() - if (this.camp._meta.self == sourceCamp._meta.self) { + if (this.camp._meta.self === sourceCamp._meta.self) { // same camp; use came category this.entityData.category = sourceCategory._meta.self } else { // different camp; use category with same short-name const categories = this.camp .categories() - .allItems.filter((c) => c.short == sourceCategory.short) + .allItems.filter((c) => c.short === sourceCategory.short) - if (categories.length == 1) { + if (categories.length === 1) { this.entityData.category = categories[0]._meta.self } } @@ -205,7 +205,6 @@ export default { copyActivitySource: null, }) } else { - this.canReadClipboard = 'unknown' // clear the variable parts of the form on exit this.copyActivitySource = null this.copyActivitySourceUrl = null @@ -221,8 +220,8 @@ export default { if (activityProxy != null) { activityProxy._meta.load.then( (activity) => { - this.$set(this, 'copyActivitySource', activity) - this.$set(this, 'copyContent', activity != null) + this.copyActivitySource = activity + this.copyContent = true this.copyActivitySourceUrlLoading = false }, () => { @@ -230,15 +229,15 @@ export default { } ) } else { - this.$set(this, 'copyActivitySource', null) - this.$set(this, 'copyContent', false) + this.copyActivitySource = null + this.copyContent = false this.copyActivitySourceUrlLoading = false } // if Paste-Popover is shown, close it now if (this.copyActivitySourceUrlShowPopover) { this.$nextTick(() => { - this.$set(this, 'copyActivitySourceUrlShowPopover', false) + this.copyActivitySourceUrlShowPopover = false }) } }, @@ -252,18 +251,15 @@ export default { refreshCopyActivitySource() { navigator.permissions.query({ name: 'clipboard-read' }).then( (p) => { - this.$set(this, 'clipboardPermission', p.state) - this.$set(this, 'copyActivitySource', null) + this.clipboardPermission = p.state + this.copyActivitySource = null - if (p.state == 'granted') { + if (p.state === 'granted') { navigator.clipboard .readText() - .then((url) => { - this.getCopyActivitySource(url).then((activityProxy) => { - activityProxy._meta.load.then((activity) => - this.$set(this, 'copyActivitySource', activity) - ) - }) + .then(async (url) => { + const copyActivitySource = await this.getCopyActivitySource(url) + this.copyActivitySource = await copyActivitySource?._meta.load }) .catch(() => { this.clipboardPermission = 'unaccessable' @@ -282,7 +278,7 @@ export default { url = url.substring(window.location.origin.length) const match = router.matcher.match(url) - if (match.name == 'activity') { + if (match.name === 'activity') { const scheduleEntry = await this.api .get() .scheduleEntries({ id: match.params['scheduleEntryId'] }) diff --git a/frontend/src/components/program/picasso/PicassoEntry.vue b/frontend/src/components/program/picasso/PicassoEntry.vue index 2c0c432104..e2f5ec394c 100644 --- a/frontend/src/components/program/picasso/PicassoEntry.vue +++ b/frontend/src/components/program/picasso/PicassoEntry.vue @@ -272,7 +272,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/locales/de.json b/frontend/src/locales/de.json index 6df666e6bd..8946d9fc27 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -65,6 +65,7 @@ }, "campCategories": { "create": "Block-Kategorie erstellen", + "pasteCategory": "Kopierte Kategorie einfügen", "title": "Block-Kategorien" }, "campConditionalFields": { @@ -118,6 +119,13 @@ "title": "Status bearbeiten" }, "dialogCategoryCreate": { + "clearClipboard": "Zwischenablage leeren", + "clipboard": "Zwischenablage", + "copyCategoryOrActivity": "Kategorie oder Aktivität kopieren", + "copyContent": "Inhalt kopieren", + "copyPasteCategory": "Kategorie kopieren & einfügen", + "copySourceInfo": "Hier kannst du die URL einer Block-Kategorie oder einer Aktivität einfügen um dessen Inhalte zu kopieren.", + "pasteCategory": "Kopierte Kategorie oder Aktivität einfügen", "title": "Block-Kategorie erstellen" }, "dialogMaterialListCreate": { @@ -176,6 +184,13 @@ "createLayoutHelp": "Hier kannst du die Vorlage für neue {categoryShort}-Blöcke definieren.{br}Blockinhalt & Layout bereits erstellter {categoryShort}-Blöcke, werden nicht angepasst.", "layout": "Layout", "noTemplate": "Keine Vorlage" + }, + "copyCategoryInfoDialog": { + "allow": "Jetzt erlauben", + "denied": "Du hast das Lesen von der Zwischenablage untersagt. Du kannst daher kopierte Kategorien nicht einfügen.", + "description": "Damit du eine kopierte Kategorie einfügen kannst, musst du eCamp erlauben deine Zwischenablage zu lesen.", + "granted": "Du kannst nun kopierte Kategorien einfügen.", + "title": "Kategorie kopieren & einfügen" } }, "collaborator": { @@ -350,7 +365,7 @@ "clipboard": "Zwischenablage", "copyActivity": "Aktivität kopieren", "copyActivityContent": "Inhalt von Aktivität kopieren", - "copyPastActivity": "Aktivität kopieren & einfügen", + "copyPasteActivity": "Aktivität kopieren & einfügen", "copySourceInfo": "Hier kannst du die URL einer Aktivität einfügen um dessen Inhalte zu kopieren.", "pasteActivity": "Kopierte Aktivität einfügen" }, @@ -664,6 +679,7 @@ }, "category": { "category": { + "copyCategory": "Kategorie kopieren", "deleteCategory": "Kategorie löschen", "properties": "Eigenschaften", "template": "Vorlage für neue Blöcke" diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c73cdbf7bb..af4a2ff40f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -65,6 +65,7 @@ }, "campCategories": { "create": "Create activity category", + "pasteCategory": "Paste category", "title": "Activity categories" }, "campConditionalFields": { @@ -118,6 +119,13 @@ "title": "Edit activity state" }, "dialogCategoryCreate": { + "clearClipboard": "Clear clipboard", + "clipboard": "Clipboard", + "copyCategoryOrActivity": "Copy category or activity", + "copyContent": "Copy content", + "copyPasteCategory": "Copy & paste category", + "copySourceInfo": "Here you can paste the URL of a category or an activity to copy its contents.", + "pasteCategory": "paste category or activity", "title": "Create activity category" }, "dialogMaterialListCreate": { @@ -176,6 +184,13 @@ "createLayoutHelp": "Here you can define the template for new {categoryShort} activities.{br}The content & layout of already created {categoryShort} activities will not be adjusted.", "layout": "Layout", "noTemplate": "No template" + }, + "copyActivityInfoDialog": { + "allow": "Allow now", + "denied": "You have denied access to your clipboard. Therefore, you cannot paste copied categories.", + "description": "In order to paste a copied category, you must allow eCamp to read your clipboard.", + "granted": "You can now paste copied categories.", + "title": "Copy & paste category" } }, "collaborator": { @@ -350,7 +365,7 @@ "clipboard": "Clipboard", "copyActivity": "Copy activity", "copyActivityContent": "Copy content from activity", - "copyPastActivity": "Copy & paste activity", + "copyPasteActivity": "Copy & paste activity", "copySourceInfo": "Here you can paste the URL of an activity to copy its contents.", "pasteActivity": "paste activity" }, @@ -663,6 +678,7 @@ }, "category": { "category": { + "copyCategory": "Copy category", "deleteCategory": "Delete category", "properties": "Properties", "template": "Template for new activities" diff --git a/frontend/src/views/category/Category.vue b/frontend/src/views/category/Category.vue index 18e55785fe..ae88dce0ff 100644 --- a/frontend/src/views/category/Category.vue +++ b/frontend/src/views/category/Category.vue @@ -16,14 +16,24 @@