Add a dropdown on participate menu, disallow listing participations

Now requires quering the person endpoint to know if an actor
participates in an event, organizers can make authenticated requests to
event { participants { } } to see the pending / approved participants.

Also closes #174

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-09-26 16:38:58 +02:00
parent 8a3e606c15
commit 757d2cabec
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
34 changed files with 655 additions and 439 deletions

View File

@ -71,49 +71,10 @@ export default class App extends Vue {
@import "variables";
/* Bulma imports */
@import "~bulma/sass/utilities/_all";
@import "~bulma/sass/base/_all.sass";
@import "~bulma/sass/components/card.sass";
@import "~bulma/sass/components/media.sass";
@import "~bulma/sass/components/message.sass";
@import "~bulma/sass/components/modal.sass";
@import "~bulma/sass/components/navbar.sass";
@import "~bulma/sass/components/pagination.sass";
@import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/components/list.sass";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass";
@import "~bulma/sass/form/_all";
@import "~bulma/sass/elements/icon.sass";
@import "~bulma/sass/elements/image.sass";
@import "~bulma/sass/elements/other.sass";
@import "~bulma/sass/elements/progress.sass";
@import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification";
@import "~bulma/sass/elements/table";
@import "~bulma/sass/grid/_all.sass";
@import "~bulma/sass/layout/_all.sass";
@import "~bulma/bulma";
/* Buefy imports */
@import "~buefy/src/scss/utils/_all";
@import "~buefy/src/scss/components/datepicker";
@import "~buefy/src/scss/components/notices";
@import "~buefy/src/scss/components/dropdown";
@import "~buefy/src/scss/components/autocomplete";
@import "~buefy/src/scss/components/form";
@import "~buefy/src/scss/components/modal";
@import "~buefy/src/scss/components/progress";
@import "~buefy/src/scss/components/tag";
@import "~buefy/src/scss/components/taginput";
@import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table";
@import "~buefy/src/scss/components/tabs";
@import "~buefy/src/scss/buefy";
.router-enter-active,
.router-leave-active {

View File

@ -6,7 +6,7 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
@Component({})
@Component
export default class DateTimePicker extends Vue {
@Prop({ required: true, type: Date }) value!: Date;
@Prop({ required: false, type: String, default: 'Datetime' }) label!: string;

View File

@ -1,64 +1,66 @@
<template>
<article class="box columns">
<div class="content column">
<div class="title-wrapper">
<div class="date-component" v-if="!mergedOptions.hideDate">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<h2 class="title" ref="title">{{ participation.event.title }}</h2>
</div>
<div>
<span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span>
<span v-if="participation.actor.id === participation.event.organizerActor.id">{{ $t("You're organizing this event") }}</span>
<span v-else>
<span v-if="participation.event.beginsOn < new Date()">{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span>
|
<span>{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span>
</span>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if=" participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock_opened" v-if=" participation.event.visibility === EventVisibility.RESTRICTED" />
<b-icon icon="lock" v-if=" participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column">
<span v-if="!participation.event.options.maximumAttendeeCapacity">
{{ $tc('{count} participants', participation.event.participantStats.approved, { count: participation.event.participantStats.approved })}}
</span>
<b-progress
v-if="participation.event.options.maximumAttendeeCapacity > 0"
type="is-primary"
size="is-medium"
:value="participation.event.participantStats.approved * 100 / participation.event.options.maximumAttendeeCapacity" show-value>
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.approved, total: participation.event.options.maximumAttendeeCapacity }) }}
</b-progress>
<span
v-if="participation.event.participantStats.unapproved > 0">
{{ $tc('{count} requests waiting', participation.event.participantStats.unapproved, { count: participation.event.participantStats.unapproved })}}
</span>
</span>
<article class="box">
<div class="title-wrapper">
<div class="date-component" v-if="!mergedOptions.hideDate">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<h2 class="title" ref="title">{{ participation.event.title }}</h2>
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } }">
<b-icon icon="pencil" /> {{ $t('Edit') }}
</router-link>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</router-link>
</li>
<li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>
</li>
</ul>
<div class="columns">
<div class="content column">
<div>
<span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span>
<span v-if="participation.actor.id === participation.event.organizerActor.id">{{ $t("You're organizing this event") }}</span>
<span v-else>
<span v-if="participation.event.beginsOn < new Date()">{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span>
|
<span>{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span>
</span>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if=" participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock_opened" v-if=" participation.event.visibility === EventVisibility.RESTRICTED" />
<b-icon icon="lock" v-if=" participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column">
<span v-if="!participation.event.options.maximumAttendeeCapacity">
{{ $tc('{count} participants', participation.event.participantStats.approved, { count: participation.event.participantStats.approved })}}
</span>
<b-progress
v-if="participation.event.options.maximumAttendeeCapacity > 0"
type="is-primary"
size="is-medium"
:value="participation.event.participantStats.approved * 100 / participation.event.options.maximumAttendeeCapacity" show-value>
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.approved, total: participation.event.options.maximumAttendeeCapacity }) }}
</b-progress>
<span
v-if="participation.event.participantStats.unapproved > 0">
{{ $tc('{count} requests waiting', participation.event.participantStats.unapproved, { count: participation.event.participantStats.unapproved })}}
</span>
</span>
</div>
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } }">
<b-icon icon="pencil" /> {{ $t('Edit') }}
</router-link>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</router-link>
</li>
<li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>
</li>
</ul>
</div>
</div>
</article>
</template>

View File

@ -0,0 +1,111 @@
<template>
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<button class="button is-success" type="button" slot="trigger">
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave">
{{ $t('Cancel my participation…')}}
</b-dropdown-item>
</b-dropdown>
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled">
<button class="button is-success" type="button" slot="trigger">
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
<!-- {{ $t('Change my identity…')}}-->
<!-- </b-dropdown-item>-->
<b-dropdown-item :value="false" aria-role="listitem" @click="confirmLeave">
{{ $t('Cancel my participation request…')}}
</b-dropdown-item>
</b-dropdown>
<small>{{ $t('Participation requested!')}}</small><br />
<small>{{ $t('Waiting for organization team approval.')}}</small>
</div>
<b-dropdown aria-role="list" position="is-bottom-left" v-if="!participation">
<button class="button is-primary" type="button" slot="trigger">
<template>
<span>{{ $t('Participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)">
<div class="media">
<div class="media-left">
<figure class="image is-32x32" v-if="currentActor.avatar">
<img class="is-rounded" :src="currentActor.avatar.url" alt="" />
</figure>
</div>
<div class="media-content">
<span>{{ $t('with {identity}', {identity: currentActor.preferredUsername }) }}</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal">
{{ $t('with another identity…')}}
</b-dropdown-item>
</b-dropdown>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
@Component
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor);
}
joinModal() {
this.$emit('joinModal');
}
confirmLeave() {
this.$emit('confirmLeave');
}
}
</script>
<style lang="scss">
.participation-button {
.dropdown {
display: flex;
justify-content: flex-end;
&.dropdown-disabled button {
opacity: 0.5;
}
}
button {
font-size: 1.5rem;
}
}
</style>

View File

@ -1,92 +0,0 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Join event {title}', {title: event.title}) }}</p>
</header>
<section class="modal-card-body is-flex">
<div class="media">
<div
class="media-left">
<b-icon
icon="alert"
type="is-warning"
size="is-large"/>
</div>
<div class="media-content">
<p>{{ $t('Do you want to participate in {title}?', {title: event.title}) }}?</p>
<b-field :label="$t('Identity')">
<identity-picker v-model="identity"></identity-picker>
</b-field>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">
{{ $t('The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved')}}
</p>
<p v-if="!event.local">
{{ $t('The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.') }}
</p>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="close">
{{ $t('Cancel') }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="confirm">
{{ $t('Confirm my particpation') }}
</button>
</footer>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent, EventJoinOptions } from '@/types/event.model';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import { IPerson } from '@/types/actor';
@Component({
components: {
IdentityPicker,
},
mounted() {
this.$data.isActive = true;
},
})
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: Object }) event! : IEvent;
@Prop({ type: Object }) defaultIdentity!: IPerson;
isActive: boolean = false;
identity: IPerson = this.defaultIdentity;
EventJoinOptions = EventJoinOptions;
confirm() {
this.onConfirm(this.identity);
}
/**
* Close the Dialog.
*/
close() {
this.isActive = false;
this.$emit('close');
}
}
</script>
<style lang="scss">
.modal-card .modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@ -10,6 +10,9 @@ const participantQuery = `
},
name,
id
},
event {
id
}
`;
@ -52,7 +55,7 @@ const optionsQuery = `
`;
export const FETCH_EVENT = gql`
query($uuid:UUID!, $roles: String) {
query($uuid:UUID!) {
event(uuid: $uuid) {
id,
uuid,
@ -95,9 +98,6 @@ export const FETCH_EVENT = gql`
# preferredUsername,
# name,
# },
participants (roles: $roles) {
${participantQuery}
},
participantStats {
approved,
unapproved
@ -363,9 +363,10 @@ export const DELETE_EVENT = gql`
`;
export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String, $actorId: ID!) {
event(uuid: $uuid) {
participants(page: $page, limit: $limit, roles: $roles) {
id,
participants(page: $page, limit: $limit, roles: $roles, actorId: $actorId) {
${participantQuery}
},
participantStats {
@ -375,3 +376,21 @@ export const PARTICIPANTS = gql`
}
}
`;
export const EVENT_PERSON_PARTICIPATION = gql`
query($name: String!, $eventId: ID!) {
person(preferredUsername: $name) {
id,
participations(eventId: $eventId) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
`;

View File

@ -17,8 +17,13 @@
"Are you sure you want to delete this event? This action cannot be reverted.": "Are you sure you want to delete this event? This action cannot be reverted.",
"Before you can login, you need to click on the link inside it to validate your account": "Before you can login, you need to click on the link inside it to validate your account",
"By {name}": "By {name}",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Cancel my participation…",
"Cancel": "Cancel",
"Category": "Category",
"Change my identity…": "Change my identity…",
"Change my password": "Change my password",
"Change password": "Change password",
"Change": "Change",
"Clear": "Clear",
"Click to select": "Click to select",
@ -82,6 +87,7 @@
"Group": "Group",
"Groups": "Groups",
"I create an identity": "I create an identity",
"I participate": "I participate",
"I want to approve every participation request": "I want to approve every participation request",
"Identities": "Identities",
"Identity {displayName} created": "Identity {displayName} created",
@ -116,6 +122,7 @@
"My events": "My events",
"My identities": "My identities",
"Name": "Name",
"New password": "New password",
"No address defined": "No address defined",
"No events found": "No events found",
"No group found": "No group found",
@ -123,6 +130,7 @@
"No participants yet.": "No participants yet.",
"No results for \"{queryText}\"": "No results for \"{queryText}\"",
"Number of places": "Number of places",
"Old password": "Old password",
"One person is going": "No one is going | One person is going | {approved} persons are going",
"Only accessible through link and search (private)": "Only accessible through link and search (private)",
"Opened reports": "Opened reports",
@ -133,8 +141,11 @@
"Otherwise this identity will just be removed from the group administrators.": "Otherwise this identity will just be removed from the group administrators.",
"Page limited to my group (asks for auth)": "Page limited to my group (asks for auth)",
"Participants": "Participants",
"Participate": "Participate",
"Participation approval": "Participation approval",
"Participation requested!": "Participation requested!",
"Password (confirmation)": "Password (confirmation)",
"Password change": "Password change",
"Password reset": "Password reset",
"Password": "Password",
"Past events": "Passed events",
@ -187,6 +198,7 @@
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved",
"The event title will be ellipsed.": "The event title will be ellipsed.",
"The page you're looking for doesn't exist.": "The page you're looking for doesn't exist.",
"The password was successfully changed": "The password was successfully changed",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "The report will be sent to the moderators of your instance. You can explain why you report this content below.",
"The {date} at {time}": "The {date} at {time}",
"The {date} from {startTime} to {endTime}": "The {date} from {startTime} to {endTime}",
@ -208,6 +220,7 @@
"View event page": "View event page",
"View everything": "View everything",
"Visible everywhere on the web (public)": "Visible everywhere on the web (public)",
"Waiting for organization team approval.": "Waiting for organization team approval.",
"Waiting list": "Waiting list",
"We just sent an email to {email}": "We just sent an email to {email}",
"Website / URL": "Website / URL",
@ -220,6 +233,7 @@
"You announced that you're going to this event.": "You announced that you're going to this event.",
"You are already logged-in.": "You are already logged-in.",
"You are an organizer.": "You are an organizer.",
"You have been disconnected": "You have been disconnected",
"You have one event in {days} days.": "You have no events in {days} days | You have one event in {days} days. | You have {count} events in {days} days",
"You have one event today.": "You have no events today | You have one event today. | You have {count} events today",
"You have one event tomorrow.": "You have no events tomorrow | You have one event tomorrow. | You have {count} events tomorrow",
@ -233,6 +247,8 @@
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
"iCal Feed": "iCal Feed",
"meditate a bit": "meditate a bit",
"with another identity…": "with another identity…",
"with {identity}": "with {identity}",
"{actor}'s avatar": "{actor}'s avatar",
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "{count} participants",

View File

@ -17,8 +17,13 @@
"Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain⋅e de vouloir supprimer cet événement ? Cette action ne peut être annulée.",
"Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte",
"By {name}": "Par {name}",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Annuler ma participation…",
"Cancel": "Annuler",
"Category": "Catégorie",
"Change my identity…": "Changer mon identité…",
"Change my password": "Modifier mon mot de passe",
"Change password": "Modifier mot de passe",
"Change": "Modifier",
"Clear": "Effacer",
"Click to select": "Cliquez pour sélectionner",
@ -82,6 +87,7 @@
"Group": "Groupe",
"Groups": "Groupes",
"I create an identity": "Je crée une identité",
"I participate": "Je participe",
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
"Identities": "Identités",
"Identity {displayName} created": "Identité {displayName} créée",
@ -116,6 +122,7 @@
"My events": "Mes événements",
"My identities": "Mes identités",
"Name": "Nom",
"New password": "Nouveau mot de passe",
"No address defined": "Aucune adresse définie",
"No events found": "Aucun événement trouvé",
"No group found": "Aucun groupe trouvé",
@ -123,6 +130,7 @@
"No participants yet.": "Pas de participants pour le moment.",
"No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »",
"Number of places": "Nombre de places",
"Old password": "Ancien mot de passe",
"One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont",
"Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts",
@ -133,8 +141,11 @@
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.",
"Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
"Participants": "Participants",
"Participate": "Participer",
"Participation approval": "Validation des participations",
"Participation requested!": "Participation demandée !",
"Password (confirmation)": "Mot de passe (confirmation)",
"Password change": "Changement de mot de passe",
"Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe",
"Past events": "Événements passés",
@ -187,6 +198,7 @@
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "L'organisateur⋅ice de l'événement a choisi d'approuver manuellement les participations à cet événement. Vous recevrez une notification lorsque votre participation sera approuvée",
"The event title will be ellipsed.": "Le titre de l'événement sera ellipsé.",
"The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.",
"The password was successfully changed": "Le mot de passe a été changé avec succès",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "Le signalement sera envoyé aux modérateur⋅ices de votre instance. Vous pouvez expliquer pourquoi vous signalez ce contenu ci-dessous.",
"The {date} at {time}": "Le {date} à {time}",
"The {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
@ -208,6 +220,7 @@
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
"Visible everywhere on the web (public)": "Visible partout sur le web (public)",
"Waiting for organization team approval.": "En attente d'approbation par l'organisation.",
"Waiting list": "Liste d'attente",
"We just sent an email to {email}": "Nous venons d'envoyer un email à {email}",
"Website / URL": "Site web / URL",
@ -220,6 +233,7 @@
"You announced that you're going to this event.": "Vous avez annoncé vous rendre à cet événement.",
"You are already logged-in.": "Vous êtes déjà connecté.",
"You are an organizer.": "Vous êtes un organisateur.",
"You have been disconnected": "Vous avez été déconnecté⋅e",
"You have one event in {days} days.": "Vous n'avez pas d'événements dans {days} jours | Vous avez un événement dans {days} jours. | Vous avez {count} événements dans {days} jours",
"You have one event today.": "Vous n'avez pas d'évenement aujourd'hui | Vous avez un événement aujourd'hui. | Vous avez {count} événements aujourd'hui",
"You have one event tomorrow.": "Vous n'avez pas d'événement demain | Vous avez un événement demain. | Vous avez {count} événements demain",
@ -233,6 +247,8 @@
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
"iCal Feed": "Flux iCal",
"meditate a bit": "méditez un peu",
"with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}",
"{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Un⋅e participant⋅e|{count} participant⋅e⋅s",

View File

@ -1,5 +1,5 @@
import { ICurrentUser } from '@/types/current-user.model';
import { IEvent } from '@/types/event.model';
import { IEvent, IParticipant } from '@/types/event.model';
import { Actor, IActor } from '@/types/actor/actor.model';
export interface IFeedToken {
@ -11,11 +11,13 @@ export interface IFeedToken {
export interface IPerson extends IActor {
feedTokens: IFeedToken[];
goingToEvents: IEvent[];
participations: IParticipant[];
}
export class Person extends Actor implements IPerson {
feedTokens: IFeedToken[] = [];
goingToEvents: IEvent[] = [];
participations: IParticipant[] = [];
constructor(hash: IPerson | {} = {}) {
super(hash);

View File

@ -1,29 +1,22 @@
<template>
<div class="identity-picker">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Pick an identity') }}</p>
</header>
<section class="modal-card-body">
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
<small>{{ identity.name }}</small>
</div>
</div>
</a>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t('Pick an identity') }}</p>
</header>
<section class="modal-card-body">
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
<small>{{ identity.name }}</small>
</div>
</div>
</section>
</a>
</div>
</b-modal>
</section>
<slot name="footer"></slot>
</div>
</template>
<script lang="ts">
@ -40,22 +33,13 @@ import { IDENTITIES } from '@/graphql/actor';
})
export default class IdentityPicker extends Vue {
@Prop() value!: IActor;
isComponentModalActive: boolean = false;
identities: IActor[] = [];
currentIdentity: IActor = this.value;
changeCurrentIdentity(identity: IActor) {
this.currentIdentity = identity;
this.$emit('input', identity);
this.isComponentModalActive = false;
}
}
</script>
<style lang="scss">
.identity-picker img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>
</script>

View File

@ -0,0 +1,39 @@
<template>
<div class="identity-picker">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<identity-picker :currentIdentity="currentIdentity" @input="relay" />
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor } from '@/types/actor';
import IdentityPicker from './IdentityPicker.vue';
@Component({
components: { IdentityPicker },
})
export default class IdentityPickerWrapper extends Vue {
@Prop() value!: IActor;
isComponentModalActive: boolean = false;
currentIdentity: IActor = this.value;
relay(identity: IActor) {
this.currentIdentity = identity;
this.$emit('input', identity);
this.isComponentModalActive = false;
}
}
</script>
<style lang="scss">
.identity-picker img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
</style>

View File

@ -92,6 +92,7 @@ export default class Register extends Vue {
domain: null,
feedTokens: [],
goingToEvents: [],
participations: [],
};
errors: object = {};
validationSent: boolean = false;

View File

@ -29,7 +29,7 @@ import {EventJoinOptions} from "@/types/event.model";
<address-auto-complete v-model="event.physicalAddress" />
<b-field :label="$t('Organizer')">
<identity-picker v-model="event.organizerActor"></identity-picker>
<identity-picker-wrapper v-model="event.organizerActor"></identity-picker-wrapper>
</b-field>
<div class="field">
@ -188,7 +188,6 @@ import {
EventModel,
EventStatus,
EventVisibility,
EventVisibilityJoinOptions,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { Person } from '@/types/actor';
@ -200,10 +199,10 @@ import { TAGS } from '@/graphql/tags';
import { ITag } from '@/types/tag.model';
import AddressAutoComplete from '@/components/Event/AddressAutoComplete.vue';
import { buildFileFromIPicture, buildFileVariable } from '@/utils/image';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
@Component({
components: { AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor, IdentityPicker },
components: { IdentityPickerWrapper, AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor },
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,

View File

@ -1,3 +1,6 @@
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
<template>
<div>
<b-loading :active.sync="$apollo.loading"></b-loading>
@ -10,7 +13,7 @@
<img src="https://picsum.photos/600/200/">
</figure>
</div>
<section class="container">
<section>
<div class="title-and-participate-button">
<div class="title-wrapper">
<div class="date-component">
@ -18,21 +21,21 @@
</div>
<h1 class="title">{{ event.title }}</h1>
</div>
<span v-if="event.participantStats.approved > 0 && !actorIsParticipant()">
{{ $tc('One person is going', event.participantStats.approved, {approved: event.participantStats.approved}) }}
</span>
<span v-else>
{{ $tc('You and one other person are going to this event', event.participantStats.approved - 1, {approved: event.participantStats.approved - 1}) }}
</span>
<div v-if="!actorIsOrganizer()" class="participate-button has-text-centered">
<a v-if="!actorIsParticipant()" @click="isJoinModalActive = true" class="button is-large is-primary is-rounded">
<b-icon icon="circle-outline"></b-icon>
{{ $t('Join') }}
</a>
<a v-if="actorIsParticipant()" @click="confirmLeave()" class="button is-large is-primary is-rounded">
<b-icon icon="check-circle"></b-icon>
{{ $t('Leave') }}
</a>
<div class="has-text-right">
<small v-if="event.participantStats.approved > 0 && !actorIsParticipant">
{{ $tc('One person is going', event.participantStats.approved, {approved: event.participantStats.approved}) }}
</small>
<small v-else>
{{ $tc('You and one other person are going to this event', event.participantStats.approved - 1, {approved: event.participantStats.approved - 1}) }}
</small>
<participation-button
v-if="currentActor.id && !actorIsOrganizer"
:participation="participations[0]"
:current-actor="currentActor"
@joinEvent="joinEvent"
@joinModal="isJoinModalActive = true"
@confirmLeave="confirmLeave"
/>
</div>
</div>
<div class="metadata columns">
@ -60,8 +63,8 @@
</p>
</div>
<div class="column sidebar">
<div class="field has-addons">
<p class="control" v-if="actorIsOrganizer()">
<div class="field has-addons" v-if="currentActor.id">
<p class="control" v-if="actorIsOrganizer">
<router-link
class="button"
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
@ -69,7 +72,7 @@
{{ $t('Edit') }}
</router-link>
</p>
<p class="control" v-if="actorIsOrganizer()">
<p class="control" v-if="actorIsOrganizer">
<a class="button is-danger" @click="openDeleteEventModalWrapper">
{{ $t('Delete') }}
</a>
@ -133,26 +136,6 @@
</div>
</div>
</div>
<section class="container">
<h3 class="title">{{ $t('Participants') }}</h3>
<router-link v-if="currentActor.id === event.organizerActor.id" :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: event.uuid } }">
{{ $t('Manage participants') }}
</router-link>
<span v-if="event.participants.length === 0">{{ $t('No participants yet.') }}</span>
<div class="columns">
<div
class="column"
v-for="participant in event.participants"
:key="participant.id"
>
<figure class="image is-48x48">
<img v-if="!participant.actor.avatar.url" src="https://picsum.photos/48/48/" class="is-rounded">
<img v-else :src="participant.actor.avatar.url" class="is-rounded">
</figure>
<span>{{ participant.actor.preferredUsername }}</span>
</div>
</div>
</section>
<section class="share">
<div class="container">
<div class="columns">
@ -188,19 +171,35 @@
<report-modal :on-confirm="reportEvent" :title="$t('Report this event')" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" />
</b-modal>
<b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal">
<participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" />
<identity-picker v-model="identity">
<template v-slot:footer>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="isJoinModalActive = false">
{{ $t('Cancel') }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="joinEvent(identity)">
{{ $t('Confirm my particpation') }}
</button>
</footer>
</template>
</identity-picker>
</b-modal>
</div>
</div>
</template>
<script lang="ts">
import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
import { RouteName } from '@/router';
import { IPerson, Person } from '@/types/actor';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import BIcon from 'buefy/src/components/icon/Icon.vue';
@ -208,11 +207,11 @@ import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import ParticipationModal from '@/components/Event/ParticipationModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event';
import { EventRouteName } from '@/router/event';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
@Component({
components: {
@ -222,7 +221,8 @@ import { EventRouteName } from '@/router/event';
BIcon,
DateCalendarIcon,
ReportModal,
ParticipationModal,
IdentityPicker,
ParticipationButton,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
@ -233,13 +233,25 @@ import { EventRouteName } from '@/router/event';
variables() {
return {
uuid: this.uuid,
roles: [ParticipantRole.CREATOR, ParticipantRole.MODERATOR, ParticipantRole.MODERATOR, ParticipantRole.PARTICIPANT].join(),
};
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
variables() {
return {
eventId: this.event.id,
name: this.currentActor.preferredUsername,
};
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
},
},
},
})
export default class Event extends EventMixin {
@ -247,13 +259,17 @@ export default class Event extends EventMixin {
event!: IEvent;
currentActor!: IPerson;
validationSent: boolean = false;
identity: IPerson = new Person();
participations: IParticipant[] = [];
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
EventVisibility = EventVisibility;
EventRouteName = EventRouteName;
mounted() {
this.identity = this.currentActor;
}
/**
* Delete the event, then redirect to home.
@ -298,6 +314,24 @@ export default class Event extends EventMixin {
},
update: (store, { data }) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: identity.preferredUsername },
});
if (participationCachedData == null) return;
const { person } = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
person.participations.push(data.joinEvent);
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: identity.preferredUsername },
data: { person },
});
const cachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (cachedData == null) return;
const { event } = cachedData;
@ -306,9 +340,13 @@ export default class Event extends EventMixin {
return;
}
event.participants = event.participants.concat([data.joinEvent]);
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.unapproved = event.participantStats.unapproved + 1;
} else {
event.participantStats.approved = event.participantStats.approved + 1;
}
store.writeQuery({ query: FETCH_EVENT, data: { event } });
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
},
});
} catch (error) {
@ -338,19 +376,38 @@ export default class Event extends EventMixin {
},
update: (store, { data }) => {
if (data == null) return;
const cachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (cachedData == null) return;
const { event } = cachedData;
if (event === null) {
console.error('Cannot update event participant cache, because of null value.');
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: this.currentActor.preferredUsername },
});
if (participationCachedData == null) return;
const { person } = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
const participation = person.participations[0];
person.participations = [];
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, name: this.currentActor.preferredUsername },
data: { person },
});
event.participants = event.participants
.filter(p => p.actor.id !== data.leaveEvent.actor.id);
event.participantStats.approved = event.participantStats.approved - 1;
store.writeQuery({ query: FETCH_EVENT, data: { event } });
const eventCachedData = store.readQuery<{ event: IEvent }>({ query: FETCH_EVENT, variables: { uuid: this.event.uuid } });
if (eventCachedData == null) return;
const { event } = eventCachedData;
if (event === null) {
console.error('Cannot update event cache, because of null value.');
return;
}
if (participation.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.unapproved = event.participantStats.unapproved - 1;
} else {
event.participantStats.approved = event.participantStats.approved - 1;
}
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
},
});
} catch (error) {
@ -369,17 +426,14 @@ export default class Event extends EventMixin {
document.body.removeChild(link);
}
actorIsParticipant() {
if (this.actorIsOrganizer()) return true;
get actorIsParticipant() {
if (this.actorIsOrganizer) return true;
return this.currentActor &&
this.event.participants
.some(participant => participant.actor.id === this.currentActor.id);
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
}
actorIsOrganizer() {
return this.currentActor && this.event.organizerActor &&
this.currentActor.id === this.event.organizerActor.id;
get actorIsOrganizer() {
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
}
get twitterShareUrl(): string {

View File

@ -68,6 +68,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 10,
roles: [ParticipantRole.PARTICIPANT].join(),
actorId: this.currentActor.id,
};
},
},
@ -79,6 +80,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 20,
roles: [ParticipantRole.CREATOR].join(),
actorId: this.currentActor.id,
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
@ -91,6 +93,7 @@ import { IPerson } from '@/types/actor';
page: 1,
limit: 20,
roles: [ParticipantRole.NOT_APPROVED].join(),
actorId: this.currentActor.id,
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),

View File

@ -35,7 +35,6 @@
<h3 class="title">
{{ $t("Upcoming") }}
</h3>
<pre>{{ Array.from(goingToEvents.entries()) }}</pre>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-for="row in goingToEvents" class="upcoming-events">
<span class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
@ -53,13 +52,12 @@
{{ $tc('You have one event in {days} days.', row[1].length, {count: row[1].length, days: calculateDiffDays(row[0])}) }}
</h3>
</span>
<div class="level">
<div>
<EventListCard
v-for="participation in row[1]"
v-if="isInLessThanSevenDays(row[0])"
:key="participation[1].event.uuid"
:participation="participation[1]"
class="level-item"
/>
</div>
</div>
@ -72,12 +70,11 @@
{{ $t("Last week") }}
</h3>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div class="level">
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
class="level-item"
:options="{ hideDate: false }"
/>
</div>
@ -295,12 +292,6 @@ export default class Home extends Vue {
}
}
.upcoming-events {
.level {
margin-left: 4rem;
}
}
section.container {
margin: auto auto 3rem;
}

View File

@ -169,7 +169,7 @@ export default class Report extends Vue {
report.notes = report.notes.concat([note]);
store.writeQuery({ query: REPORT, data: { report } });
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
},
});
@ -235,7 +235,7 @@ export default class Report extends Vue {
const updatedReport = data.updateReportStatus;
report.status = updatedReport.status;
store.writeQuery({ query: REPORT, data: { report } });
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
},
});
} catch (error) {

View File

@ -593,12 +593,12 @@ defmodule Mobilizon.Events do
@spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
[Participant.t()]
def list_participants_for_event(