Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy & paste activity #3993

Merged
merged 20 commits into from
Dec 17, 2023
Merged
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
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the source start with a capital letter

*/
#[ApiProperty(example: '/activities/1a2b3c4d')]
#[Groups(['create'])]
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
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;
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
$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);
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
}

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,
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
'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
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
$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'">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor:
Could we keep the button to request access also in the denied case?
On some browsers (e.g. mobile) the button to grant the permission is not that easy to find.

{{ $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',
pmattmann marked this conversation as resolved.
Show resolved Hide resolved
}
},
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 (
manuelmeister marked this conversation as resolved.
Show resolved Hide resolved
(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