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
34 changed files with 655 additions and 439 deletions

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) {