Merge branch 'participation-fixes' into 'master'

Participation panel revamp and fixes

See merge request framasoft/mobilizon!478
This commit is contained in:
Thomas Citharel 2020-06-18 16:02:54 +02:00
commit fe14d2ed25
23 changed files with 398 additions and 500 deletions

View File

@ -1 +0,0 @@
VUE_APP_INJECT_COMMENT = <meta name="server-injected-data" />

View File

@ -6,8 +6,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" /> <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<!-- <title><%= htmlWebpackPlugin.options.title %></title> -->
<!-- <%= VUE_APP_INJECT_COMMENT %> -->
<meta name="server-injected-data" /> <meta name="server-injected-data" />
</head> </head>

View File

@ -10,7 +10,8 @@ a {
&[href="#comments"], &[href="#comments"],
&.router-link-active, &.router-link-active,
&.comment-link, &.comment-link,
&.pagination-link { &.pagination-link,
&.datepicker-cell {
text-decoration: none; text-decoration: none;
} }
} }

View File

@ -100,7 +100,7 @@
<b-button <b-button
:disabled="newComment.text.trim().length === 0" :disabled="newComment.text.trim().length === 0"
native-type="submit" native-type="submit"
type="is-info" type="is-primary"
>{{ $t("Post a reply") }}</b-button >{{ $t("Post a reply") }}</b-button
> >
</span> </span>

View File

@ -60,10 +60,17 @@
> >
<span v-if="participation.event.options.maximumAttendeeCapacity !== 0"> <span v-if="participation.event.options.maximumAttendeeCapacity !== 0">
{{ {{
$t("{approved} / {total} seats", { $tc(
approved: participation.event.participantStats.participant, "{available}/{capacity} available places",
total: participation.event.options.maximumAttendeeCapacity, participation.event.options.maximumAttendeeCapacity -
}) (participation.event.participantStats.going - 1),
{
available:
participation.event.options.maximumAttendeeCapacity -
(participation.event.participantStats.going - 1),
capacity: participation.event.options.maximumAttendeeCapacity,
}
)
}} }}
</span> </span>
<span v-else> <span v-else>
@ -79,6 +86,7 @@
@click=" @click="
gotToWithCheck(participation, { gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS, name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: participation.event.uuid }, params: { eventId: participation.event.uuid },
}) })
" "

View File

@ -1,205 +0,0 @@
<template>
<b-table
:data="data"
ref="queueTable"
detailed
detail-key="id"
:checked-rows.sync="checkedRows"
checkable
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
:show-detail-icon="false"
:loading="this.$apollo.loading"
paginated
backend-pagination
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="total"
:per-page="perPage"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(page) => $emit('page-change', page)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="insertedAt" :label="$t('Date')" sortable>
<b-tag type="is-success" class="has-text-centered"
>{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}</b-tag
>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" sortable v-if="showRole">
<span v-if="props.row.role === ParticipantRole.CREATOR">
{{ $t("Organizer") }}
</span>
<span v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ $t("Participant") }}
</span>
</b-table-column>
<b-table-column field="actor.preferredUsername" :label="$t('Participant')" sortable>
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon
class="media-left"
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
size="is-large"
icon="incognito"
/>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ props.row.actor.preferredUsername }}</span
>
</span>
<span v-else>
{{ $t("Anonymous participant") }}
</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="metadata.message" :label="$t('Message')">
<span
@click="toggleQueueDetails(props.row)"
:class="{
'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
}"
v-if="props.row.metadata && props.row.metadata.message"
>
{{ props.row.metadata.message | ellipsize }}
</span>
<span v-else class="has-text-grey">
{{ $t("No message") }}
</span>
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article v-html="nl2br(props.row.metadata.message)" />
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button
@click="acceptParticipants(checkedRows)"
type="is-success"
v-if="canAcceptParticipants"
>
{{
$tc(
"No participant to approve|Approve participant|Approve {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button
@click="refuseParticipants(checkedRows)"
type="is-danger"
v-if="canRefuseParticipants"
>
{{
$tc(
"No participant to reject|Reject participant|Reject {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import { IParticipant, ParticipantRole } from "../../types/event.model";
import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach";
const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({
filters: {
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat("…"),
},
})
export default class ParticipationTable extends Vue {
@Prop({ required: true, type: Array }) data!: IParticipant[];
@Prop({ required: true, type: Number }) total!: number;
@Prop({ required: true, type: Function }) acceptParticipant!: Function;
@Prop({ required: true, type: Function }) refuseParticipant!: Function;
@Prop({ required: false, type: Boolean, default: false }) showRole!: boolean;
@Prop({ required: false, type: Number, default: 20 }) perPage!: number;
@Ref("queueTable") readonly queueTable!: any;
checkedRows: IParticipant[] = [];
MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
nl2br = nl2br;
ParticipantRole = ParticipantRole;
toggleQueueDetails(row: IParticipant) {
if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return;
this.queueTable.toggleDetails(row);
}
async acceptParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.acceptParticipant(participant);
});
this.checkedRows = [];
}
async refuseParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.refuseParticipant(participant);
});
this.checkedRows = [];
}
/**
* We can accept participants if at least one of them is not approved
*/
get canAcceptParticipants(): boolean {
return this.checkedRows.some((participant: IParticipant) =>
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role)
);
}
/**
* We can refuse participants if at least one of them is something different than not approved
*/
get canRefuseParticipants(): boolean {
return this.checkedRows.some(
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
);
}
}
</script>
<style lang="scss" scoped>
.ellipsed-message {
cursor: pointer;
}
.table {
span.tag {
height: initial;
}
}
</style>

View File

@ -212,6 +212,7 @@ export const LOGGED_USER_PARTICIPATIONS = gql`
} }
} }
participantStats { participantStats {
going
notApproved notApproved
participant participant
} }

View File

@ -1,5 +1,4 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import { COMMENT_FIELDS_FRAGMENT } from "@/graphql/comment";
const participantQuery = ` const participantQuery = `
role, role,
@ -466,6 +465,8 @@ export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String, $actorId: ID!) { query($uuid: UUID!, $page: Int, $limit: Int, $roles: String, $actorId: ID!) {
event(uuid: $uuid) { event(uuid: $uuid) {
id, id,
uuid,
title,
participants(page: $page, limit: $limit, roles: $roles, actorId: $actorId) { participants(page: $page, limit: $limit, roles: $roles, actorId: $actorId) {
${participantsQuery} ${participantsQuery}
}, },

View File

@ -317,7 +317,6 @@
"Registration is currently closed.": "Registration is currently closed.", "Registration is currently closed.": "Registration is currently closed.",
"Registrations are restricted by whitelisting.": "Registrations are restricted by whitelisting.", "Registrations are restricted by whitelisting.": "Registrations are restricted by whitelisting.",
"Reject": "Reject", "Reject": "Reject",
"Rejected participations": "Rejected participations",
"Rejected": "Rejected", "Rejected": "Rejected",
"Reopen": "Reopen", "Reopen": "Reopen",
"Reply": "Reply", "Reply": "Reply",
@ -330,7 +329,6 @@
"Reported identity": "Reported identity", "Reported identity": "Reported identity",
"Reported": "Reported", "Reported": "Reported",
"Reports": "Reports", "Reports": "Reports",
"Requests": "Requests",
"Resend confirmation email": "Resend confirmation email", "Resend confirmation email": "Resend confirmation email",
"Reset my password": "Reset my password", "Reset my password": "Reset my password",
"Resolved": "Resolved", "Resolved": "Resolved",
@ -343,8 +341,6 @@
"Search": "Search", "Search": "Search",
"Searching…": "Searching…", "Searching…": "Searching…",
"Send email": "Send email", "Send email": "Send email",
"Send me an email to reset my password": "Send me an email to reset my password",
"Send me the confirmation email once again": "Send me the confirmation email once again",
"Send the report": "Send the report", "Send the report": "Send the report",
"Set an URL to a page with your own terms.": "Set an URL to a page with your own terms.", "Set an URL to a page with your own terms.": "Set an URL to a page with your own terms.",
"Settings": "Settings", "Settings": "Settings",
@ -419,7 +415,6 @@
"View page on {hostname} (in a new window)": "View page on {hostname} (in a new window)", "View page on {hostname} (in a new window)": "View page on {hostname} (in a new window)",
"Visible everywhere on the web (public)": "Visible everywhere on the web (public)", "Visible everywhere on the web (public)": "Visible everywhere on the web (public)",
"Waiting for organization team approval.": "Waiting for organization team approval.", "Waiting for organization team approval.": "Waiting for organization team approval.",
"Waiting list": "Waiting list",
"Warning": "Warning", "Warning": "Warning",
"We just sent an email to {email}": "We just sent an email to {email}", "We just sent an email to {email}": "We just sent an email to {email}",
"We want to develop a <b>digital common</b>, that everyone can make their own, which respects <b>privacy and activism by design</b>.": "We want to develop a <b>digital common</b>, that everyone can make their own, which respects <b>privacy and activism by design</b>.", "We want to develop a <b>digital common</b>, that everyone can make their own, which respects <b>privacy and activism by design</b>.": "We want to develop a <b>digital common</b>, that everyone can make their own, which respects <b>privacy and activism by design</b>.",
@ -647,5 +642,14 @@
"Change timezone": "Change timezone", "Change timezone": "Change timezone",
"Select a language": "Select a language", "Select a language": "Select a language",
"This event is accessible only through it's link. Be careful where you post this link.": "This event is accessible only through it's link. Be careful where you post this link.", "This event is accessible only through it's link. Be careful where you post this link.": "This event is accessible only through it's link. Be careful where you post this link.",
"This event has been cancelled.": "This event has been cancelled." "This event has been cancelled.": "This event has been cancelled.",
"Actions": "Actions",
"Everything": "Everything",
"Not approved": "Not approved",
"No participant matches the filters": "No participant matches the filters",
"Send the confirmation email again": "Send the confirmation email again",
"Forgot your password?": "Forgot your password?",
"Enter your email address below, and we'll email you instructions on how to change your password.": "Enter your email address below, and we'll email you instructions on how to change your password.",
"Submit": "Submit",
"Email address": "Email address"
} }

View File

@ -670,5 +670,14 @@
"Change timezone": "Changer de fuseau horaire", "Change timezone": "Changer de fuseau horaire",
"Select a language": "Choisissez une langue", "Select a language": "Choisissez une langue",
"This event is accessible only through it's link. Be careful where you post this link.": "Cet événement est accessible uniquement à travers son lien. Faites attention où vous le diffusez.", "This event is accessible only through it's link. Be careful where you post this link.": "Cet événement est accessible uniquement à travers son lien. Faites attention où vous le diffusez.",
"This event has been cancelled.": "Cet événement a été annulé." "This event has been cancelled.": "Cet événement a été annulé.",
"Actions": "Actions",
"Everything": "Tous",
"Not approved": "Non approuvé·es",
"No participant matches the filters": "Aucun·e participant·e ne correspond aux filtres",
"Send the confirmation email again": "Envoyer l'email de confirmation à nouveau",
"Forgot your password?": "Mot de passe oublié ?",
"Enter your email address below, and we'll email you instructions on how to change your password.": "Indiquez votre adresse e-mail ci-dessous. Nous vous enverrons des instructions concernant la modification de votre mot de passe.",
"Submit": "Valider",
"Email address": "Adresse email"
} }

View File

@ -7,6 +7,7 @@ export enum UserRouteName {
RESEND_CONFIRMATION = "ResendConfirmation", RESEND_CONFIRMATION = "ResendConfirmation",
SEND_PASSWORD_RESET = "SendPasswordReset", SEND_PASSWORD_RESET = "SendPasswordReset",
PASSWORD_RESET = "PasswordReset", PASSWORD_RESET = "PasswordReset",
EMAIL_VALIDATE = "EMAIL_VALIDATE",
VALIDATE = "Validate", VALIDATE = "Validate",
LOGIN = "Login", LOGIN = "Login",
} }
@ -54,7 +55,7 @@ export const userRoutes: RouteConfig[] = [
}, },
{ {
path: "/validate/email/:token", path: "/validate/email/:token",
name: UserRouteName.VALIDATE, name: UserRouteName.EMAIL_VALIDATE,
component: () => import("@/views/User/EmailValidate.vue"), component: () => import("@/views/User/EmailValidate.vue"),
props: true, props: true,
meta: { requiresAuth: false }, meta: { requiresAuth: false },

View File

@ -168,14 +168,17 @@
v-if="actorIsOrganizer && event.draft === false" v-if="actorIsOrganizer && event.draft === false"
:to="{ name: RouteName.PARTICIPATIONS, params: { eventId: event.uuid } }" :to="{ name: RouteName.PARTICIPATIONS, params: { eventId: event.uuid } }"
> >
<!-- We retire one because of the event creator who is a participant -->
<span v-if="event.options.maximumAttendeeCapacity"> <span v-if="event.options.maximumAttendeeCapacity">
{{ {{
$tc( $tc(
"{available}/{capacity} available places", "{available}/{capacity} available places",
event.options.maximumAttendeeCapacity - event.participantStats.going, event.options.maximumAttendeeCapacity -
(event.participantStats.going - 1),
{ {
available: available:
event.options.maximumAttendeeCapacity - event.participantStats.going, event.options.maximumAttendeeCapacity -
(event.participantStats.going - 1),
capacity: event.options.maximumAttendeeCapacity, capacity: event.options.maximumAttendeeCapacity,
} }
) )
@ -183,8 +186,8 @@
</span> </span>
<span v-else> <span v-else>
{{ {{
$tc("No one is going to this event", event.participantStats.going, { $tc("No one is going to this event", event.participantStats.going - 1, {
going: event.participantStats.going, going: event.participantStats.going - 1,
}) })
}} }}
</span> </span>
@ -226,7 +229,7 @@
</p> </p>
<b-dropdown position="is-bottom-left" aria-role="list"> <b-dropdown position="is-bottom-left" aria-role="list">
<b-button slot="trigger" role="button" icon-right="dots-horizontal"> <b-button slot="trigger" role="button" icon-right="dots-horizontal">
Actions {{ $t("Actions") }}
<!-- <b-icon icon="dots-horizontal" /> --> <!-- <b-icon icon="dots-horizontal" /> -->
</b-button> </b-button>
<b-dropdown-item <b-dropdown-item

View File

@ -1,77 +1,189 @@
import {ParticipantRole} from "@/types/event.model";
<template> <template>
<main class="container"> <main class="container">
<b-tabs type="is-boxed" v-if="event" v-model="activeTab"> <section v-if="event">
<b-tab-item> <nav class="breadcrumb" aria-label="breadcrumbs">
<template slot="header"> <ul>
<b-icon icon="account-multiple" /> <li>
<span <router-link :to="{ name: RouteName.MY_EVENTS }">{{ $t("My events") }}</router-link>
>{{ $t("Participants") }} <b-tag rounded> {{ participantStats.going }} </b-tag> </li>
</span> <li>
</template> <router-link
<template> :to="{
<section v-if="participants && participants.total > 0"> name: RouteName.EVENT,
params: { uuid: event.uuid },
}"
>{{ event.title }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.PARTICIPANTS,
params: { uuid: event.uuid },
}"
>{{ $t("Participants") }}</router-link
>
</li>
</ul>
</nav>
<h2 class="title">{{ $t("Participants") }}</h2> <h2 class="title">{{ $t("Participants") }}</h2>
<ParticipationTable <b-field :label="$t('Status')" horizontal>
:data="participants.elements" <b-select v-model="roles">
:accept-participant="acceptParticipant" <option value="">
:refuse-participant="refuseParticipant" {{ $t("Everything") }}
:showRole="true" </option>
:total="participants.total" <option :value="ParticipantRole.CREATOR">
:perPage="PARTICIPANTS_PER_PAGE" {{ $t("Organizer") }}
@page-change="(page) => (participantPage = page)" </option>
<option :value="ParticipantRole.PARTICIPANT">
{{ $t("Participant") }}
</option>
<option :value="ParticipantRole.NOT_APPROVED">
{{ $t("Not approved") }}
</option>
<option :value="ParticipantRole.REJECTED">
{{ $t("Rejected") }}
</option>
</b-select>
</b-field>
<b-table
:data="event.participants.elements"
ref="queueTable"
detailed
detail-key="id"
:checked-rows.sync="checkedRows"
checkable
:is-row-checkable="(row) => row.role !== ParticipantRole.CREATOR"
checkbox-position="left"
:show-detail-icon="false"
:loading="this.$apollo.loading"
paginated
backend-pagination
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="event.participants.total"
:per-page="PARTICIPANTS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="actor.preferredUsername" :label="$t('Participant')">
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon
class="media-left"
v-else-if="props.row.actor.preferredUsername === 'anonymous'"
size="is-large"
icon="incognito"
/> />
</section> <b-icon class="media-left" v-else size="is-large" icon="account-circle" />
</template> <div class="media-content">
</b-tab-item> <div class="content">
<b-tab-item :disabled="participantStats.notApproved === 0"> <span v-if="props.row.actor.preferredUsername !== 'anonymous'">
<template slot="header"> <span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
<b-icon icon="account-multiple-plus" /> ><br />
<span <span class="is-size-7 has-text-grey"
>{{ $t("Requests") }} <b-tag rounded> {{ participantStats.notApproved }} </b-tag> >@{{ props.row.actor.preferredUsername }}</span
>
</span> </span>
</template> <span v-else>
<template> {{ $t("Anonymous participant") }}
<section v-if="queue && queue.total > 0">
<h2 class="title">{{ $t("Waiting list") }}</h2>
<ParticipationTable
:data="queue.elements"
:accept-participant="acceptParticipant"
:refuse-participant="refuseParticipant"
:total="queue.total"
:perPage="PARTICIPANTS_PER_PAGE"
@page-change="(page) => (queuePage = page)"
/>
</section>
</template>
</b-tab-item>
<b-tab-item :disabled="participantStats.rejected === 0">
<template slot="header">
<b-icon icon="account-multiple-minus"></b-icon>
<span
>{{ $t("Rejected") }} <b-tag rounded> {{ participantStats.rejected }} </b-tag>
</span> </span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')">
<b-tag type="is-primary" v-if="props.row.role === ParticipantRole.CREATOR">
{{ $t("Organizer") }}
</b-tag>
<b-tag v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
{{ $t("Participant") }}
</b-tag>
<b-tag type="is-warning" v-else-if="props.row.role === ParticipantRole.NOT_APPROVED">
{{ $t("Not approved") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === ParticipantRole.REJECTED">
{{ $t("Rejected") }}
</b-tag>
</b-table-column>
<b-table-column field="metadata.message" :label="$t('Message')">
<span
@click="toggleQueueDetails(props.row)"
:class="{
'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH,
}"
v-if="props.row.metadata && props.row.metadata.message"
>
{{ props.row.metadata.message | ellipsize }}
</span>
<span v-else class="has-text-grey">
{{ $t("No message") }}
</span>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
</template> </template>
<template> <template slot="detail" slot-scope="props">
<section v-if="rejected && rejected.total > 0"> <article v-html="nl2br(props.row.metadata.message)" />
<h2 class="title">{{ $t("Rejected participations") }}</h2> </template>
<ParticipationTable <template slot="empty">
:data="rejected.elements" <section class="section">
:accept-participant="acceptParticipant" <div class="content has-text-grey has-text-centered">
:refuse-participant="refuseParticipant" <p>{{ $t("No participant matches the filters") }}</p>
:total="rejected.total" </div>
:perPage="PARTICIPANTS_PER_PAGE"
@page-change="(page) => (rejectedPage = page)"
/>
</section> </section>
</template> </template>
</b-tab-item> <template slot="bottom-left">
</b-tabs> <div class="buttons">
<b-button
@click="acceptParticipants(checkedRows)"
type="is-success"
:disabled="!canAcceptParticipants"
>
{{
$tc(
"No participant to approve|Approve participant|Approve {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
<b-button
@click="refuseParticipants(checkedRows)"
type="is-danger"
:disabled="!canRefuseParticipants"
>
{{
$tc(
"No participant to reject|Reject participant|Reject {number} participants",
checkedRows.length,
{ number: checkedRows.length }
)
}}
</b-button>
</div>
</template>
</b-table>
</section>
</main> </main>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator";
import { import {
IEvent, IEvent,
IEventParticipantStats, IEventParticipantStats,
@ -85,15 +197,17 @@ import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor"; import { IPerson } from "../../types/actor";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import ParticipationTable from "../../components/Event/ParticipationTable.vue";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";
import { DataProxy } from "apollo-cache";
import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach";
import RouteName from "../../router/name";
const PARTICIPANTS_PER_PAGE = 20; const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130; const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({ @Component({
components: { components: {
ParticipationTable,
ParticipantCard, ParticipantCard,
}, },
apollo: { apollo: {
@ -103,12 +217,13 @@ const MESSAGE_ELLIPSIS_LENGTH = 130;
config: CONFIG, config: CONFIG,
event: { event: {
query: PARTICIPANTS, query: PARTICIPANTS,
fetchPolicy: "network-only",
variables() { variables() {
return { return {
uuid: this.eventId, uuid: this.eventId,
page: 1, page: 1,
limit: PARTICIPANTS_PER_PAGE, limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.PARTICIPANT].join(), roles: this.roles,
actorId: this.currentActor.id, actorId: this.currentActor.id,
}; };
}, },
@ -116,60 +231,6 @@ const MESSAGE_ELLIPSIS_LENGTH = 130;
return !this.currentActor.id; return !this.currentActor.id;
}, },
}, },
participants: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: this.participantPage,
limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.CREATOR, ParticipantRole.PARTICIPANT].join(),
actorId: this.currentActor.id,
};
},
update(data) {
return this.dataTransform(data);
},
skip() {
return !this.currentActor.id;
},
},
queue: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: this.queuePage,
limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.NOT_APPROVED].join(),
actorId: this.currentActor.id,
};
},
update(data) {
return this.dataTransform(data);
},
skip() {
return !this.currentActor.id;
},
},
rejected: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: this.rejectedPage,
limit: PARTICIPANTS_PER_PAGE,
roles: [ParticipantRole.REJECTED].join(),
actorId: this.currentActor.id,
};
},
update(data) {
return this.dataTransform(data);
},
skip() {
return !this.currentActor.id;
},
},
}, },
filters: { filters: {
ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat("…"), ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat("…"),
@ -180,19 +241,7 @@ export default class Participants extends Vue {
page = 1; page = 1;
limit = 10; limit = PARTICIPANTS_PER_PAGE;
participants!: Paginate<IParticipant>;
participantPage = 1;
queue!: Paginate<IParticipant>;
queuePage = 1;
rejected!: Paginate<IParticipant>;
rejectedPage = 1;
event!: IEvent; event!: IEvent;
@ -202,19 +251,21 @@ export default class Participants extends Vue {
currentActor!: IPerson; currentActor!: IPerson;
hasMoreParticipants = false;
activeTab = 0;
PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE; PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE;
dataTransform(data: { event: IEvent }): Paginate<Participant> { checkedRows: IParticipant[] = [];
return {
total: data.event.participants.total, roles: ParticipantRole = ParticipantRole.PARTICIPANT;
elements: data.event.participants.elements.map(
(participation: IParticipant) => new Participant(participation) RouteName = RouteName;
),
}; @Ref("queueTable") readonly queueTable!: any;
mounted() {
const roleQuery = this.$route.query.role as string;
if (Object.values(ParticipantRole).includes(roleQuery as ParticipantRole)) {
this.roles = roleQuery as ParticipantRole;
}
} }
get participantStats(): IEventParticipantStats | null { get participantStats(): IEventParticipantStats | null {
@ -222,20 +273,9 @@ export default class Participants extends Vue {
return this.event.participantStats; return this.event.participantStats;
} }
@Watch("participantStats", { deep: true }) @Watch("page")
watchParticipantStats(stats: IEventParticipantStats) {
if (!stats) return;
if (
(stats.notApproved === 0 && this.activeTab === 1) ||
(stats.rejected === 0 && this.activeTab === 2)
) {
this.activeTab = 0;
}
}
loadMoreParticipants() { loadMoreParticipants() {
this.page += 1; this.$apollo.queries.event.fetchMore({
this.$apollo.queries.participants.fetchMore({
// New variables // New variables
variables: { variables: {
page: this.page, page: this.page,
@ -243,13 +283,17 @@ export default class Participants extends Vue {
}, },
// Transform the previous result with new data // Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: (previousResult, { fetchMoreResult }) => {
const newParticipations = fetchMoreResult.event.participants; const oldParticipants = previousResult.event.participants;
this.hasMoreParticipants = newParticipations.length === this.limit; const newParticipants = fetchMoreResult.event.participants;
return { return {
loggedUser: { event: {
__typename: previousResult.event.__typename, ...previousResult.event,
participations: [...previousResult.event.participants, ...newParticipations], participants: {
elements: [...oldParticipants.elements, ...newParticipants.elements],
total: newParticipants.total,
__typename: oldParticipants.__typename,
},
}, },
}; };
}, },
@ -266,23 +310,6 @@ export default class Participants extends Vue {
role: ParticipantRole.PARTICIPANT, role: ParticipantRole.PARTICIPANT,
}, },
}); });
if (data) {
this.queue.elements = this.queue.elements.filter(
(participant) => participant.id !== data.updateParticipation.id
);
this.rejected.elements = this.rejected.elements.filter(
(participant) => participant.id !== data.updateParticipation.id
);
this.event.participantStats.going += 1;
if (participant.role === ParticipantRole.NOT_APPROVED) {
this.event.participantStats.notApproved -= 1;
}
if (participant.role === ParticipantRole.REJECTED) {
this.event.participantStats.rejected -= 1;
}
participant.role = ParticipantRole.PARTICIPANT;
this.event.participants.elements.push(participant);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@ -298,52 +325,77 @@ export default class Participants extends Vue {
role: ParticipantRole.REJECTED, role: ParticipantRole.REJECTED,
}, },
}); });
if (data) {
this.event.participants.elements = this.event.participants.elements.filter(
(participant) => participant.id !== data.updateParticipation.id
);
this.queue.elements = this.queue.elements.filter(
(participant) => participant.id !== data.updateParticipation.id
);
this.event.participantStats.rejected += 1;
if (participant.role === ParticipantRole.PARTICIPANT) {
this.event.participantStats.participant -= 1;
this.event.participantStats.going -= 1;
}
if (participant.role === ParticipantRole.NOT_APPROVED) {
this.event.participantStats.notApproved -= 1;
}
participant.role = ParticipantRole.REJECTED;
this.rejected.elements = this.rejected.elements.filter(
(participantIn) => participantIn.id !== participant.id
);
this.rejected.elements.push(participant);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
async acceptParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.acceptParticipant(participant);
});
this.checkedRows = [];
}
async refuseParticipants(participants: IParticipant[]) {
await asyncForEach(participants, async (participant: IParticipant) => {
await this.refuseParticipant(participant);
});
this.checkedRows = [];
}
/**
* We can accept participants if at least one of them is not approved
*/
get canAcceptParticipants(): boolean {
return this.checkedRows.some((participant: IParticipant) =>
[ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role)
);
}
/**
* We can refuse participants if at least one of them is something different than not approved
*/
get canRefuseParticipants(): boolean {
return this.checkedRows.some(
(participant: IParticipant) => participant.role !== ParticipantRole.REJECTED
);
}
MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
nl2br = nl2br;
toggleQueueDetails(row: IParticipant) {
if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return;
this.queueTable.toggleDetails(row);
}
} }
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../../variables.scss";
section { section {
padding: 1rem 0; padding: 1rem 0;
} }
/deep/ .tabs.is-boxed { .table {
.ellipsed-message {
cursor: pointer;
}
span.tag {
&.is-primary {
background-color: $primary;
}
}
}
nav.breadcrumb {
a { a {
text-decoration: none; text-decoration: none;
} }
} }
</style> </style>
<style lang="scss">
nav.tabs li {
margin: 3rem 0 0;
}
.tab-content {
background: #fff;
}
</style>

View File

@ -67,6 +67,11 @@
>{{ $t("Forgot your password ?") }}</router-link >{{ $t("Forgot your password ?") }}</router-link
> >
</p> </p>
<router-link
class="button is-text"
:to="{ name: RouteName.RESEND_CONFIRMATION, params: { email: credentials.email } }"
>{{ $t("Didn't receive the instructions ?") }}</router-link
>
<p class="control" v-if="config && config.registrationsOpen"> <p class="control" v-if="config && config.registrationsOpen">
<router-link <router-link
class="button is-text" class="button is-text"

View File

@ -6,13 +6,16 @@
{{ $t("Resend confirmation email") }} {{ $t("Resend confirmation email") }}
</h1> </h1>
<form v-if="!validationSent" @submit="resendConfirmationAction"> <form v-if="!validationSent" @submit="resendConfirmationAction">
<b-field label="Email"> <b-field :label="$t('Email address')">
<b-input aria-required="true" required type="email" v-model="credentials.email" /> <b-input aria-required="true" required type="email" v-model="credentials.email" />
</b-field> </b-field>
<p class="control has-text-centered"> <p class="control">
<b-button type="is-primary" native-type="submit"> <b-button type="is-primary" native-type="submit">
{{ $t("Send me the confirmation email once again") }} {{ $t("Send the confirmation email again") }}
</b-button> </b-button>
<router-link :to="{ name: RouteName.LOGIN }" class="button is-text">{{
$t("Cancel")
}}</router-link>
</p> </p>
</form> </form>
<div v-else> <div v-else>
@ -37,6 +40,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { validateEmailField, validateRequiredField } from "../../utils/validators"; import { validateEmailField, validateRequiredField } from "../../utils/validators";
import { RESEND_CONFIRMATION_EMAIL } from "../../graphql/auth"; import { RESEND_CONFIRMATION_EMAIL } from "../../graphql/auth";
import RouteName from "../../router/name";
@Component @Component
export default class ResendConfirmation extends Vue { export default class ResendConfirmation extends Vue {
@ -50,6 +54,8 @@ export default class ResendConfirmation extends Vue {
error = false; error = false;
RouteName = RouteName;
state = { state = {
email: { email: {
status: null, status: null,

View File

@ -3,8 +3,15 @@
<div class="columns is-mobile is-centered"> <div class="columns is-mobile is-centered">
<div class="column is-half-desktop"> <div class="column is-half-desktop">
<h1 class="title"> <h1 class="title">
{{ $t("Password reset") }} {{ $t("Forgot your password?") }}
</h1> </h1>
<p>
{{
$t(
"Enter your email address below, and we'll email you instructions on how to change your password."
)
}}
</p>
<b-message <b-message
title="Error" title="Error"
type="is-danger" type="is-danger"
@ -15,13 +22,16 @@
{{ error }} {{ error }}
</b-message> </b-message>
<form @submit="sendResetPasswordTokenAction" v-if="!validationSent"> <form @submit="sendResetPasswordTokenAction" v-if="!validationSent">
<b-field label="Email"> <b-field :label="$t('Email address')">
<b-input aria-required="true" required type="email" v-model="credentials.email" /> <b-input aria-required="true" required type="email" v-model="credentials.email" />
</b-field> </b-field>
<p class="control has-text-centered"> <p class="control">
<b-button type="is-primary" native-type="submit"> <b-button type="is-primary" native-type="submit">
{{ $t("Send me an email to reset my password") }} {{ $t("Submit") }}
</b-button> </b-button>
<router-link :to="{ name: RouteName.LOGIN }" class="button is-text">{{
$t("Cancel")
}}</router-link>
</p> </p>
</form> </form>
<div v-else> <div v-else>
@ -41,6 +51,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { validateEmailField, validateRequiredField } from "../../utils/validators"; import { validateEmailField, validateRequiredField } from "../../utils/validators";
import { SEND_RESET_PASSWORD } from "../../graphql/auth"; import { SEND_RESET_PASSWORD } from "../../graphql/auth";
import RouteName from "../../router/name";
@Component @Component
export default class SendPasswordReset extends Vue { export default class SendPasswordReset extends Vue {
@ -52,6 +63,8 @@ export default class SendPasswordReset extends Vue {
validationSent = false; validationSent = false;
RouteName = RouteName;
errors: string[] = []; errors: string[] = [];
state = { state = {

View File

@ -92,7 +92,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
|> Enum.map(&String.to_existing_atom/1) |> Enum.map(&String.to_existing_atom/1)
end end
{:ok, Events.list_participants_for_event(event_id, roles, page, limit)} participants = Events.list_participants_for_event(event_id, roles, page, limit)
{:ok, participants}
else else
{:is_owned, nil} -> {:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"} {:error, "Moderator Actor ID is not owned by authenticated user"}
@ -115,17 +116,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
_args, _args,
%{context: %{current_user: %User{id: user_id} = _user}} = _resolution %{context: %{current_user: %User{id: user_id} = _user}} = _resolution
) do ) do
if Events.is_user_moderator_for_event?(user_id, event_id) do going = stats.participant + stats.moderator + stats.administrator + stats.creator
stats =
Map.put(
stats,
:going,
stats.participant + stats.moderator + stats.administrator + stats.creator
)
{:ok, stats} if Events.is_user_moderator_for_event?(user_id, event_id) do
{:ok, Map.put(stats, :going, going)}
else else
{:ok, %EventParticipantStats{}} {:ok, %{going: going}}
end end
end end

View File

@ -752,7 +752,6 @@ defmodule Mobilizon.Events do
end end
@moderator_roles [:moderator, :administrator, :creator] @moderator_roles [:moderator, :administrator, :creator]
@default_participant_roles [:participant] ++ @moderator_roles
@doc """ @doc """
Returns the list of participants for an event. Returns the list of participants for an event.
@ -762,13 +761,14 @@ defmodule Mobilizon.Events do
Page.t() Page.t()
def list_participants_for_event( def list_participants_for_event(
id, id,
roles \\ @default_participant_roles, roles \\ [],
page \\ nil, page \\ nil,
limit \\ nil limit \\ nil
) do ) do
id id
|> list_participants_for_event_query() |> list_participants_for_event_query()
|> filter_role(roles) |> filter_role(roles)
|> order_by(asc: :role)
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end

View File

@ -4,11 +4,11 @@ defmodule Mobilizon.Web.ErrorView do
""" """
use Mobilizon.Web, :view use Mobilizon.Web, :view
alias Mobilizon.Service.Metadata.Instance alias Mobilizon.Service.Metadata.Instance
alias Mobilizon.Web.PageView import Mobilizon.Web.Views.Utils
def render("404.html", _assigns) do def render("404.html", %{conn: conn}) do
tags = Instance.build_tags() tags = Instance.build_tags()
PageView.inject_tags(tags) inject_tags(tags, get_locale(conn))
end end
def render("404.json", _assigns) do def render("404.json", _assigns) do

View File

@ -13,10 +13,10 @@ defmodule Mobilizon.Web.PageView do
alias Mobilizon.Service.Metadata alias Mobilizon.Service.Metadata
alias Mobilizon.Service.Metadata.Instance alias Mobilizon.Service.Metadata.Instance
alias Mobilizon.Service.Metadata.Utils, as: MetadataUtils
alias Mobilizon.Federation.ActivityPub.Utils alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.Convertible alias Mobilizon.Federation.ActivityStream.Convertible
import Mobilizon.Web.Views.Utils
def render("actor.activity-json", %{conn: %{assigns: %{object: %Actor{} = actor}}}) do def render("actor.activity-json", %{conn: %{assigns: %{object: %Actor{} = actor}}}) do
actor actor
@ -59,38 +59,4 @@ defmodule Mobilizon.Web.PageView do
tags = Instance.build_tags() tags = Instance.build_tags()
inject_tags(tags, get_locale(conn)) inject_tags(tags, get_locale(conn))
end end
@spec inject_tags(List.t(), String.t()) :: {:safe, String.t()}
def inject_tags(tags, locale \\ "en") do
with {:ok, index_content} <- File.read(index_file_path()) do
do_replacements(index_content, MetadataUtils.stringify_tags(tags), locale)
end
end
@spec index_file_path :: String.t()
defp index_file_path do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
@spec replace_meta(String.t(), String.t()) :: String.t()
# TODO: Find why it's different in dev/prod and during tests
defp replace_meta(index_content, tags) do
index_content
|> String.replace("<meta name=\"server-injected-data\" />", tags)
|> String.replace("<meta name=server-injected-data>", tags)
end
@spec do_replacements(String.t(), String.t(), String.t()) :: {:safe, String.t()}
defp do_replacements(index_content, tags, locale) do
index_content
|> replace_meta(tags)
|> String.replace("<html lang=\"en\">", "<html lang=\"#{locale}\">")
|> String.replace("<html lang=en>", "<html lang=\"#{locale}\">")
|> (&{:safe, &1}).()
end
@spec get_locale(Conn.t()) :: String.t()
defp get_locale(%{private: %{cldr_locale: nil}}), do: "en"
defp get_locale(%{private: %{cldr_locale: %{requested_locale_name: locale}}}), do: locale
defp get_locale(_), do: "en"
end end

41
lib/web/views/utils.ex Normal file
View File

@ -0,0 +1,41 @@
defmodule Mobilizon.Web.Views.Utils do
@moduledoc """
Utils for views
"""
alias Mobilizon.Service.Metadata.Utils, as: MetadataUtils
@spec inject_tags(List.t(), String.t()) :: {:safe, String.t()}
def inject_tags(tags, locale \\ "en") do
with {:ok, index_content} <- File.read(index_file_path()) do
do_replacements(index_content, MetadataUtils.stringify_tags(tags), locale)
end
end
@spec index_file_path :: String.t()
defp index_file_path do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
@spec replace_meta(String.t(), String.t()) :: String.t()
# TODO: Find why it's different in dev/prod and during tests
defp replace_meta(index_content, tags) do
index_content
|> String.replace("<meta name=\"server-injected-data\" />", tags)
|> String.replace("<meta name=server-injected-data>", tags)
end
@spec do_replacements(String.t(), String.t(), String.t()) :: {:safe, String.t()}
defp do_replacements(index_content, tags, locale) do
index_content
|> replace_meta(tags)
|> String.replace("<html lang=\"en\">", "<html lang=\"#{locale}\">")
|> String.replace("<html lang=en>", "<html lang=\"#{locale}\">")
|> (&{:safe, &1}).()
end
@spec get_locale(Conn.t()) :: String.t()
def get_locale(%{private: %{cldr_locale: nil}}), do: "en"
def get_locale(%{private: %{cldr_locale: %{requested_locale_name: locale}}}), do: locale
def get_locale(_), do: "en"
end

View File

@ -2,7 +2,7 @@ defmodule Mobilizon.Storage.Repo.Migrations.RenamePostgresTypes do
use Ecto.Migration use Ecto.Migration
alias Mobilizon.Actors.{ActorVisibility, MemberRole} alias Mobilizon.Actors.{ActorVisibility, MemberRole}
alias alias Mobilizon.Conversations.CommentVisibility alias Mobilizon.Conversations.CommentVisibility
alias Mobilizon.Events.{ alias Mobilizon.Events.{
JoinOptions, JoinOptions,

View File

@ -1365,8 +1365,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
assert event.id assert event.id
|> Events.list_participants_for_event() |> Events.list_participants_for_event()
|> Map.get(:elements) |> Map.get(:elements)
|> Enum.map(& &1.id) == |> Enum.map(& &1.role) == [:rejected]
[]
end end
end end