Skip to content

Commit

Permalink
Merge pull request #3993 from pmattmann/feature/copy-activity
Browse files Browse the repository at this point in the history
Copy & paste activity
  • Loading branch information
manuelmeister authored Dec 17, 2023
2 parents ffc91bf + c8b15ec commit 45d4e38
Show file tree
Hide file tree
Showing 15 changed files with 583 additions and 20 deletions.
7 changes: 7 additions & 0 deletions api/src/Entity/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ class Activity extends BaseEntity implements BelongsToCampInterface {
#[ORM\JoinColumn(nullable: false)]
public ?Category $category = null;

/**
* Copy Contents from this Source-Activity.
*/
#[ApiProperty(example: '/activities/1a2b3c4d')]
#[Groups(['create'])]
public ?Activity $copyActivitySource;

/**
* The current assigned ProgressLabel.
*/
Expand Down
15 changes: 10 additions & 5 deletions api/src/State/ActivityCreateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
class ActivityCreateProcessor extends AbstractPersistProcessor {
public function __construct(
ProcessorInterface $decorated,
private EntityManagerInterface $em,
private EntityManagerInterface $em
) {
parent::__construct($decorated);
}
Expand All @@ -26,16 +26,21 @@ public function __construct(
* @param Activity $data
*/
public function onBefore($data, Operation $operation, array $uriVariables = [], array $context = []): Activity {
$data->camp = $data->category?->camp;

if (!isset($data->category?->rootContentNode)) {
throw new \UnexpectedValueException('Property rootContentNode of provided category is null. Object of type '.ColumnLayout::class.' expected.');
}

if (!is_a($data->category->rootContentNode, ColumnLayout::class)) {
throw new \UnexpectedValueException('Property rootContentNode of provided category is of wrong type. Object of type '.ColumnLayout::class.' expected.');
}

$data->camp = $data->category->camp;
$rootContentNodePrototype = $data->category->rootContentNode;

if (isset($data->copyActivitySource)) {
// CopyActivity Source is set -> copy it's content (rootContentNode)
$rootContentNodePrototype = $data->copyActivitySource->rootContentNode;
}

$rootContentNode = new ColumnLayout();
$rootContentNode->contentType = $this->em
->getRepository(ContentType::class)
Expand All @@ -45,7 +50,7 @@ public function onBefore($data, Operation $operation, array $uriVariables = [],

// deep copy from category root node
$entityMap = new EntityMap();
$rootContentNode->copyFromPrototype($data->category->rootContentNode, $entityMap);
$rootContentNode->copyFromPrototype($rootContentNodePrototype, $entityMap);

return $data;
}
Expand Down
66 changes: 66 additions & 0 deletions api/tests/Api/Activities/CreateActivityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,71 @@ public function testCreateActivityValidatesMissingScheduleEntries() {
$this->assertResponseStatusCodeSame(422);
}

public function testCreateActivityFromCopySourceValidatesAccess() {
static::createClientWithCredentials(['email' => static::$fixtures['user8memberOnlyInCamp2']->getEmail()])->request(
'POST',
'/activities',
['json' => $this->getExampleWritePayload(
[
'copyActivitySource' => $this->getIriFor('activity1'),
'category' => $this->getIriFor('category1camp2'),
'scheduleEntries' => [
[
'period' => $this->getIriFor('period1camp2'),
'start' => '2023-03-25T15:00:00+00:00',
'end' => '2023-03-25T16:00:00+00:00',
],
],
],
[]
)]
);

// No Access on activity1 -> BadRequest
$this->assertResponseStatusCodeSame(400);
}

public function testCreateActivityFromCopySourceWithinSameCamp() {
static::createClientWithCredentials()->request(
'POST',
'/activities',
['json' => $this->getExampleWritePayload(
[
'copyActivitySource' => $this->getIriFor('activity1'),
'category' => $this->getIriFor('category1'),
],
[]
)]
);

// Activity created
$this->assertResponseStatusCodeSame(201);
}

public function testCreateActivityFromCopySourceAcrossCamp() {
static::createClientWithCredentials()->request(
'POST',
'/activities',
['json' => $this->getExampleWritePayload(
[
'copyActivitySource' => $this->getIriFor('activity1'),
'category' => $this->getIriFor('category1camp2'),
'scheduleEntries' => [
[
'period' => $this->getIriFor('period1camp2'),
'start' => '2023-03-25T15:00:00+00:00',
'end' => '2023-03-25T16:00:00+00:00',
],
],
],
[]
)]
);

// Activity created
$this->assertResponseStatusCodeSame(201);
}

/**
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
Expand Down Expand Up @@ -509,6 +574,7 @@ public function getExampleWritePayload($attributes = [], $except = []) {
Activity::class,
Post::class,
array_merge([
'copyActivitySource' => null,
'category' => $this->getIriFor('category1'),
'progressLabel' => null,
'scheduleEntries' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@ components:
format: iri-reference
'owl:maxCardinality': 1
type: string
copyActivitySource:
description: 'Copy Contents from this Source-Activity.'
example: /activities/1a2b3c4d
format: iri-reference
'owl:maxCardinality': 1
type:
- 'null'
- string
location:
description: "The physical location where this activity's programme will be carried out."
example: Spielwiese
Expand Down Expand Up @@ -775,6 +783,14 @@ components:
format: iri-reference
'owl:maxCardinality': 1
type: string
copyActivitySource:
description: 'Copy Contents from this Source-Activity.'
example: /activities/1a2b3c4d
format: iri-reference
'owl:maxCardinality': 1
type:
- 'null'
- string
location:
description: "The physical location where this activity's programme will be carried out."
example: Spielwiese
Expand Down Expand Up @@ -1194,6 +1210,14 @@ components:
format: iri-reference
'owl:maxCardinality': 1
type: string
copyActivitySource:
description: 'Copy Contents from this Source-Activity.'
example: /activities/1a2b3c4d
format: iri-reference
'owl:maxCardinality': 1
type:
- 'null'
- string
location:
description: "The physical location where this activity's programme will be carried out."
example: Spielwiese
Expand Down
1 change: 1 addition & 0 deletions api/tests/State/ActivityCreateProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ protected function setUp(): void {
$categoryRoot->contentType = $contentType;
$this->activity->category->setRootContentNode($categoryRoot);

// EntityManager
$repository = $this->createMock(EntityRepository::class);
$this->em->method('getRepository')->willReturn($repository);
$repository->method('findOneBy')->willReturn($contentType);
Expand Down
75 changes: 75 additions & 0 deletions frontend/src/components/activity/CopyActivityInfoDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<template>
<dialog-form
v-model="showDialog"
icon="mdi-content-copy"
:title="$tc('components.activity.copyActivityInfoDialog.title')"
:cancel-action="cancel"
:cancel-label="$tc('global.button.close')"
>
<template #activator="scope">
<slot name="activator" v-bind="scope" />
</template>

<p>
{{ $tc('components.activity.copyActivityInfoDialog.description') }}
</p>
<p v-if="clipboardReadState === 'prompt'">
<center>
<v-btn color="success" @click="requestClipboardAccess">
{{ $tc('components.activity.copyActivityInfoDialog.allow') }}
</v-btn>
</center>
</p>
<p v-if="clipboardReadState === 'granted'">
{{ $tc('components.activity.copyActivityInfoDialog.granted') }}
</p>
<p v-if="clipboardReadState === 'denied'">
{{ $tc('components.activity.copyActivityInfoDialog.denied') }}
</p>
</dialog-form>
</template>

<script>
import DialogForm from '@/components/dialog/DialogForm.vue'
import DialogBase from '@/components/dialog/DialogBase.vue'
export default {
name: 'CopyActivityInfoDialog',
components: {
DialogForm,
},
extends: DialogBase,
data() {
return {
clipboardReadState: 'unknown',
}
},
async mounted() {
try {
// read current permission
const res = await navigator.permissions.query({ name: 'clipboard-read' })
this.clipboardReadState = res.state
} catch {
console.warn('clipboard permission not requestable')
}
},
methods: {
cancel() {
this.close()
},
async requestClipboardAccess() {
// if permission is not yet requested, request it
if (this.clipboardReadState === 'prompt') {
try {
await navigator.clipboard.readText()
} catch {
console.log('clipboard read is denied')
}
const res = await navigator.permissions.query({ name: 'clipboard-read' })
this.clipboardReadState = res.state
}
},
},
}
</script>
51 changes: 48 additions & 3 deletions frontend/src/components/activity/ScheduleEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,20 @@ Displays a single scheduleEntry

<v-divider />

<v-list-item @click="copyUrlToClipboard">
<v-list-item-icon>
<v-icon>mdi-content-copy</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $tc('components.activity.scheduleEntry.copyScheduleEntry') }}
</v-list-item-title>
</v-list-item>
<CopyActivityInfoDialog ref="copyInfoDialog" />

<v-divider />

<!-- remove activity -->
<dialog-entity-delete :entity="activity" @submit="onDelete">
<DialogEntityDelete :entity="activity" @submit="onDelete">
<template #activator="{ on }">
<v-list-item :disabled="!isContributor" v-on="on">
<v-list-item-icon>
Expand All @@ -158,7 +170,7 @@ Displays a single scheduleEntry
</v-list-item>
</template>
{{ $tc('components.activity.scheduleEntry.deleteWarning') }}
</dialog-entity-delete>
</DialogEntityDelete>
</v-list>
</v-menu>
</template>
Expand Down Expand Up @@ -259,22 +271,27 @@ import RootNode from '@/components/activity/RootNode.vue'
import ActivityResponsibles from '@/components/activity/ActivityResponsibles.vue'
import { dateHelperUTCFormatted } from '@/mixins/dateHelperUTCFormatted.js'
import { campRoleMixin } from '@/mixins/campRoleMixin'
import { periodRoute } from '@/router.js'
import { periodRoute, scheduleEntryRoute } from '@/router.js'
import router from '@/router.js'
import DownloadNuxtPdf from '@/components/print/print-nuxt/DownloadNuxtPdfListItem.vue'
import DownloadClientPdf from '@/components/print/print-client/DownloadClientPdfListItem.vue'
import { errorToMultiLineToast } from '@/components/toast/toasts'
import CategoryChip from '@/components/generic/CategoryChip.vue'
import CopyActivityInfoDialog from '@/components/activity/CopyActivityInfoDialog.vue'
import DialogEntityDelete from '@/components/dialog/DialogEntityDelete.vue'

export default {
name: 'ScheduleEntry',
components: {
DialogEntityDelete,
ContentCard,
ApiTextField,
RootNode,
ActivityResponsibles,
DownloadClientPdf,
DownloadNuxtPdf,
CategoryChip,
CopyActivityInfoDialog,
},
mixins: [campRoleMixin, dateHelperUTCFormatted],
provide() {
Expand Down Expand Up @@ -312,6 +329,13 @@ export default {
scheduleEntries() {
return this.activity.scheduleEntries().items
},
activityName() {
return (
(this.scheduleEntry().number ? this.scheduleEntry().number + ' ' : '') +
(this.category.short ? this.category.short + ': ' : '') +
this.activity.title
)
},
progressLabels() {
const labels = sortBy(this.camp.progressLabels().items, (l) => l.position)
return labels.map((label) => ({
Expand Down Expand Up @@ -379,6 +403,27 @@ export default {
this.editActivityTitle = true
}
},
async copyUrlToClipboard() {
try {
const res = await navigator.permissions.query({ name: 'clipboard-read' })
if (res.state == 'prompt') {
this.$refs.copyInfoDialog.open()
}
} catch {
console.warn('clipboard permission not requestable')
}

const scheduleEntry = scheduleEntryRoute(this.scheduleEntry())
const url = window.location.origin + router.resolve(scheduleEntry).href
await navigator.clipboard.writeText(url)

this.$toast.info(
this.$tc('global.toast.copied', null, { source: this.activityName }),
{
timeout: 2000,
}
)
},
onDelete() {
// redirect to Picasso
this.$router.push(periodRoute(this.scheduleEntry().period()))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<PopoverPrompt
v-model="showDialog"
type="error"
:error="error"
:submit-action="deactivateUser"
:submit-enabled="!$slots.error"
Expand Down
Loading

0 comments on commit 45d4e38

Please sign in to comment.