Add draft feature

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-10-02 17:59:07 +02:00
parent c0c0659a5d
commit 174a844ed5
22 changed files with 587 additions and 66 deletions

View File

@ -15,7 +15,7 @@
</div>
<h2 class="title" ref="title">{{ event.title }}</h2>
</div>
<span>
<span class="organizer-place-wrapper">
<span v-if="actorDisplayName && actorDisplayName !== '@'">{{ $t('By {name}', { name: actorDisplayName }) }}</span>
<span v-if="event.physicalAddress && (event.physicalAddress.locality || event.physicalAddress.description)">
- {{ event.physicalAddress.locality || event.physicalAddress.description }}
@ -142,6 +142,17 @@ export default class EventCard extends Vue {
line-height: 1em;
font-size: 1.6em;
padding-bottom: 5px;
margin-top: auto;
}
}
span.organizer-place-wrapper {
display: flex;
span:last-child {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -60,7 +60,7 @@ export const UPDATE_CURRENT_ACTOR_CLIENT = gql`
`;
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations($afterDateTime: DateTime, $beforeDateTime: DateTime $page: Int, $limit: Int) {
query LoggedUserParticipations($afterDateTime: DateTime, $beforeDateTime: DateTime, $page: Int, $limit: Int) {
loggedUser {
participations(afterDatetime: $afterDateTime, beforeDatetime: $beforeDateTime, page: $page, limit: $limit) {
event {
@ -106,6 +106,40 @@ query LoggedUserParticipations($afterDateTime: DateTime, $beforeDateTime: DateTi
}
}`;
export const LOGGED_USER_DRAFTS = gql`
query LoggedUserDrafts($page: Int, $limit: Int) {
loggedUser {
drafts(page: $page, limit: $limit) {
id,
uuid,
title,
picture {
url,
alt
},
beginsOn,
visibility,
organizerActor {
id,
preferredUsername,
name,
domain,
avatar {
url
}
},
participantStats {
approved,
unapproved
},
options {
maximumAttendeeCapacity
remainingAttendeeCapacity
}
}
}
}`;
export const IDENTITIES = gql`
query {
identities {

View File

@ -69,6 +69,7 @@ export const FETCH_EVENT = gql`
status,
visibility,
joinOptions,
draft,
picture {
id
url
@ -190,6 +191,7 @@ export const CREATE_EVENT = gql`
$status: EventStatus,
$visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$draft: Boolean,
$tags: [String],
$picture: PictureInput,
$onlineAddress: String,
@ -207,6 +209,7 @@ export const CREATE_EVENT = gql`
status: $status,
visibility: $visibility,
joinOptions: $joinOptions,
draft: $draft,
tags: $tags,
picture: $picture,
onlineAddress: $onlineAddress,
@ -224,6 +227,7 @@ export const CREATE_EVENT = gql`
status,
visibility,
joinOptions,
draft,
picture {
id
url
@ -255,6 +259,7 @@ export const EDIT_EVENT = gql`
$status: EventStatus,
$visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$draft: Boolean,
$tags: [String],
$picture: PictureInput,
$onlineAddress: String,
@ -272,6 +277,7 @@ export const EDIT_EVENT = gql`
status: $status,
visibility: $visibility,
joinOptions: $joinOptions,
draft: $draft,
tags: $tags,
picture: $picture,
onlineAddress: $onlineAddress,
@ -289,6 +295,7 @@ export const EDIT_EVENT = gql`
status,
visibility,
joinOptions,
draft,
picture {
id
url

View File

@ -13,10 +13,14 @@
"Allow all comments": "Allow all comments",
"Approve": "Approve",
"Are you going to this event?": "Are you going to this event?",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Are you sure you want to cancel the event creation? You'll lose all modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Are you sure you want to cancel the event edition? You'll lose all modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?",
"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 creation": "Cancel creation",
"Cancel edition": "Cancel edition",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Cancel my participation…",
"Cancel": "Cancel",
@ -33,6 +37,7 @@
"Comments": "Comments",
"Confirm my particpation": "Confirm my particpation",
"Confirmed: Will happen": "Confirmed: Will happen",
"Continue editing": "Continue editing",
"Country": "Country",
"Create a new event": "Create a new event",
"Create a new group": "Create a new group",
@ -59,12 +64,15 @@
"Display participation price": "Display participation price",
"Displayed name": "Displayed name",
"Do you want to participate in {title}?": "Do you want to participate in {title}?",
"Draft": "Draft",
"Drafts": "Drafts",
"Edit": "Edit",
"Either the account is already validated, either the validation token is incorrect.": "Either the account is already validated, either the validation token is incorrect.",
"Email": "Email",
"Ends on…": "Ends on…",
"Enter some tags": "Enter some tags",
"Error while validating account": "Error while validating account",
"Event already passed": "Event already passed",
"Event list": "Event list",
"Event {eventTitle} deleted": "Event {eventTitle} deleted",
"Event {eventTitle} reported": "Event {eventTitle} reported",
@ -165,6 +173,7 @@
"Public event": "Public event",
"Public feeds": "Public feeds",
"Public iCal Feed": "Public iCal Feed",
"Publish": "Publish",
"Published events": "Published events",
"RSS/Atom Feed": "RSS/Atom Feed",
"Region": "Region",
@ -172,10 +181,14 @@
"Register": "Register",
"Registration is currently closed.": "Registration is currently closed.",
"Reject": "Reject",
"Rejected participations": "Rejected participations",
"Rejected": "Rejected",
"Report this event": "Report this event",
"Report": "Report",
"Requests": "Requests",
"Resend confirmation email": "Resend confirmation email",
"Reset my password": "Reset my password",
"Save draft": "Save draft",
"Save": "Save",
"Search events, groups, etc.": "Search events, groups, etc.",
"Search results: \"{search}\"": "Search results: \"{search}\"",
@ -210,7 +223,9 @@
"To confirm, type your event title \"{eventTitle}\"": "To confirm, type your event title \"{eventTitle}\"",
"To confirm, type your identity username \"{preferredUsername}\"": "To confirm, type your identity username \"{preferredUsername}\"",
"Transfer to {outsideDomain}": "Transfer to {outsideDomain}",
"Unfortunately, your participation request was rejected by the organizers.": "Unfortunately, your participation request was rejected by the organizers.",
"Unknown error.": "Unknown error.",
"Unsaved changes": "Unsaved changes",
"Upcoming": "Upcoming",
"Update event {name}": "Update event {name}",
"Update my event": "Update my event",
@ -253,9 +268,5 @@
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "{count} participants",
"{count} requests waiting": "{count} requests waiting",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"Requests": "Requests",
"Rejected": "Rejected",
"Rejected participations": "Rejected participations",
"Unfortunately, your participation request was rejected by the organizers.": "Unfortunately, your participation request was rejected by the organizers."
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks"
}

View File

@ -13,10 +13,14 @@
"Allow all comments": "Autoriser tous les commentaires",
"Approve": "Approuver",
"Are you going to this event?": "Allez-vous à cet événement ?",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler la création de l'événement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler l'édition de l'événement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?",
"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 creation": "Annuler la création",
"Cancel edition": "Annuler l'édition",
"Cancel my participation request…": "Cancel my participation request…",
"Cancel my participation…": "Annuler ma participation…",
"Cancel": "Annuler",
@ -33,6 +37,7 @@
"Comments": "Commentaires",
"Confirm my particpation": "Confirmer ma particpation",
"Confirmed: Will happen": "Confirmé : aura lieu",
"Continue editing": "Continuer l'édition",
"Country": "Pays",
"Create a new event": "Créer un nouvel événement",
"Create a new group": "Créer un nouveau groupe",
@ -59,12 +64,15 @@
"Display participation price": "Afficher un prix de participation",
"Displayed name": "Nom affiché",
"Do you want to participate in {title}?": "Voulez-vous participer à {title} ?",
"Draft": "Brouillon",
"Drafts": "Brouillons",
"Edit": "Éditer",
"Either the account is already validated, either the validation token is incorrect.": "Soit le compte est déjà validé, soit le jeton de validation est incorrect.",
"Email": "Email",
"Ends on…": "Se termine le…",
"Enter some tags": "Écrire des tags",
"Error while validating account": "Erreur lors de la validation du compte",
"Event already passed": "Événement déjà passé",
"Event list": "Liste d'événements",
"Event {eventTitle} deleted": "Événement {eventTitle} supprimé",
"Event {eventTitle} reported": "Événement {eventTitle} signalé",
@ -165,6 +173,7 @@
"Public event": "Événement public",
"Public feeds": "Flux publics",
"Public iCal Feed": "Flux iCal public",
"Publish": "Publier",
"Published events": "Événements publiés",
"RSS/Atom Feed": "Flux RSS/Atom",
"Region": "Région",
@ -172,10 +181,14 @@
"Register": "S'inscrire",
"Registration is currently closed.": "Les inscriptions sont actuellement fermées.",
"Reject": "Rejetter",
"Rejected participations": "Participations rejetées",
"Rejected": "Rejetés",
"Report this event": "Signaler cet événement",
"Report": "Signaler",
"Requests": "Requêtes",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe",
"Save draft": "Enregistrer le brouillon",
"Save": "Enregistrer",
"Search events, groups, etc.": "Rechercher des événements, des groupes, etc.",
"Search results: \"{search}\"": "Résultats de recherche: « {search} »",
@ -210,7 +223,9 @@
"To confirm, type your event title \"{eventTitle}\"": "Pour confirmer, entrez le titre de l'événement « {eventTitle} »",
"To confirm, type your identity username \"{preferredUsername}\"": "Pour confirmer, entrez le nom de lidentité « {preferredUsername} »",
"Transfer to {outsideDomain}": "Transférer à {outsideDomain}",
"Unfortunately, your participation request was rejected by the organizers.": "Malheureusement, votre demande de participation a été refusée par les organisateur⋅ices.",
"Unknown error.": "Erreur inconnue.",
"Unsaved changes": "Modifications non enregistrées",
"Upcoming": "À venir",
"Update event {name}": "Éditer l'événement {name}",
"Update my event": "Éditer mon événement",
@ -253,9 +268,5 @@
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Un⋅e participant⋅e|{count} participant⋅e⋅s",
"{count} requests waiting": "Un⋅e demande en attente|{count} demandes en attente",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"Requests": "Requêtes",
"Rejected": "Rejetés",
"Rejected participations": "Participations rejetées",
"Unfortunately, your participation request was rejected by the organizers.": "Malheureusement, votre demande de participation a été refusée par les organisateur⋅ices."
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines"
}

View File

@ -96,15 +96,13 @@ export interface IEvent {
slug: string;
description: string;
category: Category | null;
beginsOn: Date;
endsOn: Date | null;
publishAt: Date;
status: EventStatus;
visibility: EventVisibility;
joinOptions: EventJoinOptions;
draft: boolean;
picture: IPicture | null;
@ -176,6 +174,7 @@ export class EventModel implements IEvent {
category: Category | null = Category.MEETING;
joinOptions = EventJoinOptions.FREE;
status = EventStatus.CONFIRMED;
draft = true;
publishAt = new Date();
@ -210,8 +209,8 @@ export class EventModel implements IEvent {
this.status = hash.status;
this.visibility = hash.visibility;
this.joinOptions = hash.joinOptions;
this.draft = hash.draft;
this.picture = hash.picture;
@ -240,6 +239,7 @@ export class EventModel implements IEvent {
status: this.status,
visibility: this.visibility,
joinOptions: this.joinOptions,
draft: this.draft,
tags: this.tags.map(t => t.title),
picture: this.picture,
onlineAddress: this.onlineAddress,

View File

@ -5,7 +5,7 @@
{{ $t('Change') }}
</b-button>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<identity-picker :currentIdentity="currentIdentity" @input="relay" />
<identity-picker v-model="currentIdentity" @input="relay" />
</b-modal>
</div>
</template>

View File

@ -9,7 +9,7 @@
</h1>
<div class="columns is-centered">
<form class="column is-two-thirds-desktop">
<form class="column is-two-thirds-desktop" ref="form">
<h2 class="subtitle">
{{ $t('General information') }}
</h2>
@ -170,14 +170,16 @@
{{ $t('Cancel') }}
</b-button>
</span>
<span class="navbar-item" v-if="isUpdate === false">
<b-button type="is-primary" outlined>
<!-- If an event has been published we can't make it draft anymore -->
<span class="navbar-item" v-if="event.draft === true">
<b-button type="is-primary" outlined @click="createOrUpdateDraft">
{{ $t('Save draft') }}
</b-button>
</span>
<span class="navbar-item">
<b-button type="is-primary" @click="createOrUpdate" @keyup.enter="createOrUpdate">
<b-button type="is-primary" @click="createOrUpdatePublish" @keyup.enter="createOrUpdatePublish">
<span v-if="isUpdate === false">{{ $t('Create my event') }}</span>
<span v-else-if="event.draft === true"> {{ $t('Publish') }}</span>
<span v-else> {{ $t('Update my event') }}</span>
</b-button>
</span>
@ -238,7 +240,7 @@ import {
EventVisibility, IEvent,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { Person } from '@/types/actor';
import { IActor, Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue';
import Editor from '@/components/Editor.vue';
import DateTimePicker from '@/components/Event/DateTimePicker.vue';
@ -312,7 +314,6 @@ export default class EditEvent extends Vue {
this.observer = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry) {
console.log(entry);
this.showFixedNavbar = !entry.isIntersecting;
}
}
@ -322,12 +323,29 @@ export default class EditEvent extends Vue {
this.observer.observe(this.$refs.bottomObserver as Element);
}
createOrUpdate(e: Event) {
createOrUpdateDraft(e: Event) {
e.preventDefault();
if (this.validateForm()) {
if (this.eventId) return this.updateEvent();
if (this.eventId) return this.updateEvent();
return this.createEvent();
}
}
return this.createEvent();
createOrUpdatePublish(e: Event) {
if (this.validateForm()) {
this.event.draft = false;
this.createOrUpdateDraft(e);
}
}
private validateForm() {
const form = this.$refs.form as HTMLFormElement;
if (form.checkValidity()) {
return true;
}
form.reportValidity();
return false;
}
async createEvent() {
@ -412,13 +430,16 @@ export default class EditEvent extends Vue {
* Confirm cancel
*/
confirmGoBack() {
if (!this.isEventModified) {
return this.$router.go(-1);
}
const title: string = this.isUpdate ?
this.$t('Cancel edition') as string :
this.$t('Cancel creation') as string;
const message: string = this.isUpdate ?
this.$t('Are you sure you want to cancel the event edition? You\'ll lose all modifications.',
this.$t("Are you sure you want to cancel the event edition? You'll lose all modifications.",
{ title: this.event.title }) as string :
this.$t('Are you sure you want to cancel the event creation? You\'ll lose all modifications.',
this.$t("Are you sure you want to cancel the event creation? You'll lose all modifications.",
{ title: this.event.title }) as string;
this.$buefy.dialog.confirm({

View File

@ -18,15 +18,15 @@
</div>
<h1 class="title">{{ event.title }}</h1>
</div>
<div class="has-text-right">
<div class="has-text-right" v-if="new Date(endDate) > new Date()">
<small v-if="event.participantStats.approved > 0 && !actorIsParticipant">
{{ $tc('One person is going', event.participantStats.approved, {approved: event.participantStats.approved}) }}
</small>
<small v-else>
<small v-else-if="event.participantStats.approved > 0 && actorIsParticipant">
{{ $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"
v-if="currentActor.id && !actorIsOrganizer && !event.draft"
:participation="participations[0]"
:current-actor="currentActor"
@joinEvent="joinEvent"
@ -34,15 +34,25 @@
@confirmLeave="confirmLeave"
/>
</div>
<div v-else>
<button class="button is-primary" type="button" slot="trigger" disabled>
<template>
<span>{{ $t('Event already passed')}}</span>
</template>
<b-icon icon="menu-down"></b-icon>
</button>
</div>
</div>
<div class="metadata columns">
<div class="column is-three-quarters-desktop">
<p class="tags" v-if="event.category || event.tags.length > 0">
<b-tag type="is-warning" size="is-medium" v-if="event.draft">{{ $t('Draft') }}</b-tag>
<!-- <span class="tag" v-if="event.category">{{ event.category }}</span>-->
<span class="tag" v-if="event.tags" v-for="tag in event.tags">{{ tag.title }}</span>
<span class="visibility">
<span v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('Public event') }}</span>
<span v-if="event.visibility === EventVisibility.UNLISTED">{{ $t('Private event') }}</span>
<b-tag type="is-success" v-if="event.tags" v-for="tag in event.tags">{{ tag.title }}</b-tag>
<span v-if="event.tags > 0"></span>
<span class="visibility" v-if="!event.draft">
<b-tag type="is-info" v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('Public event') }}</b-tag>
<b-tag type="is-info" v-if="event.visibility === EventVisibility.UNLISTED">{{ $t('Private event') }}</b-tag>
</span>
</p>
<div class="date-and-add-to-calendar">
@ -50,7 +60,7 @@
<b-icon icon="calendar-clock" />
<event-full-date :beginsOn="event.beginsOn" :endsOn="event.endsOn" />
</div>
<a class="add-to-calendar" @click="downloadIcsEvent()">
<a class="add-to-calendar" @click="downloadIcsEvent()" v-if="!event.draft">
<b-icon icon="calendar-plus" />
{{ $t('Add to my calendar') }}
</a>
@ -61,7 +71,7 @@
</div>
<div class="column sidebar">
<div class="field has-addons" v-if="currentActor.id">
<p class="control" v-if="actorIsOrganizer">
<p class="control" v-if="actorIsOrganizer || event.draft">
<router-link
class="button"
:to="{ name: 'EditEvent', params: {eventId: event.uuid}}"
@ -69,7 +79,7 @@
{{ $t('Edit') }}
</router-link>
</p>
<p class="control" v-if="actorIsOrganizer">
<p class="control" v-if="actorIsOrganizer || event.draft">
<a class="button is-danger" @click="openDeleteEventModalWrapper">
{{ $t('Delete') }}
</a>
@ -133,7 +143,7 @@
</div>
</div>
</div>
<section class="share">
<section class="share" v-if="!event.draft">
<div class="container">
<div class="columns">
<div class="column is-half has-text-centered">
@ -433,6 +443,10 @@ export default class Event extends EventMixin {
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
}
get endDate() {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn ? this.event.endsOn : this.event.beginsOn;
}
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${this.event.title}`;
}
@ -597,18 +611,14 @@ export default class Event extends EventMixin {
p.tags {
span {
&.tag {
&.tag.is-success {
&::before {
content: '#';
}
text-transform: uppercase;
color: #111111;
}
&.visibility::before {
content: "⋅"
}
margin: auto 5px;
}
margin-bottom: 1rem;

View File

@ -26,6 +26,19 @@
v-if="hasMoreFutureParticipations && (futureParticipations.length === limit)" @click="loadMoreFutureParticipations" size="is-large" type="is-primary">{{ $t('Load more') }}</b-button>
</div>
</section>
<section v-if="drafts.length > 0">
<h2 class="subtitle">
{{ $t('Drafts') }}
</h2>
<div class="columns is-multiline">
<EventCard
v-for="draft in drafts"
:key="draft.uuid"
:event="draft"
class="is-one-quarter-desktop column"
/>
</div>
</section>
<section v-if="pastParticipations.length > 0">
<h2 class="subtitle">
{{ $t('Past events') }}
@ -56,13 +69,15 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import { IParticipant, Participant } from '@/types/event.model';
import { LOGGED_USER_PARTICIPATIONS, LOGGED_USER_DRAFTS } from '@/graphql/actor';
import { EventModel, IEvent, IParticipant, Participant } from '@/types/event.model';
import EventListCard from '@/components/Event/EventListCard.vue';
import EventCard from '@/components/Event/EventCard.vue';
@Component({
components: {
EventCard,
EventListCard,
},
apollo: {
@ -75,6 +90,14 @@ import EventListCard from '@/components/Event/EventListCard.vue';
},
update: data => data.loggedUser.participations.map(participation => new Participant(participation)),
},
drafts: {
query: LOGGED_USER_DRAFTS,
variables: {
page: 1,
limit: 10,
},
update: data => data.loggedUser.drafts.map(event => new EventModel(event)),
},
pastParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
variables: {
@ -97,6 +120,8 @@ export default class MyEvents extends Vue {
pastParticipations: IParticipant[] = [];
hasMorePastParticipations: boolean = true;
drafts: IEvent[] = [];
private monthlyParticipations(participations: IParticipant[]): Map<string, Participant[]> {
const res = participations.filter(({ event }) => event.beginsOn != null);
res.sort(

View File

@ -32,6 +32,7 @@ defmodule Mobilizon.Events.Event do
ends_on: DateTime.t(),
title: String.t(),
status: EventStatus.t(),
draft: boolean,
visibility: EventVisibility.t(),
join_options: JoinOptions.t(),
publish_at: DateTime.t(),
@ -57,6 +58,7 @@ defmodule Mobilizon.Events.Event do
:ends_on,
:category,
:status,
:draft,
:visibility,
:publish_at,
:online_address,
@ -74,6 +76,7 @@ defmodule Mobilizon.Events.Event do
:ends_on,
:category,
:status,
:draft,
:visibility,
:join_options,
:publish_at,
@ -93,6 +96,7 @@ defmodule Mobilizon.Events.Event do
field(:ends_on, :utc_datetime)
field(:title, :string)
field(:status, EventStatus, default: :confirmed)
field(:draft, :boolean, default: false)
field(:visibility, EventVisibility, default: :public)
field(:join_options, JoinOptions, default: :free)
field(:publish_at, :utc_datetime)

View File

@ -169,6 +169,7 @@ defmodule Mobilizon.Events do
url
|> event_by_url_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Repo.one()
@ -190,18 +191,32 @@ defmodule Mobilizon.Events do
url
|> event_by_url_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Repo.one!()
end
@doc """
Gets an event by its UUID, with all associations loaded.
Gets a public event by its UUID, with all associations loaded.
"""
@spec get_public_event_by_uuid_with_preload(String.t()) :: Event.t() | nil
def get_public_event_by_uuid_with_preload(uuid) do
uuid
|> event_by_uuid_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Repo.one()
end
@doc """
Gets an event by its UUID, with all associations loaded.
"""
@spec get_own_event_by_uuid_with_preload(String.t(), integer()) :: Event.t() | nil
def get_own_event_by_uuid_with_preload(uuid, user_id) do
uuid
|> event_by_uuid_query()
|> user_events_query(user_id)
|> preload_for_event()
|> Repo.one()
end
@ -215,6 +230,7 @@ defmodule Mobilizon.Events do
|> upcoming_public_event_for_actor_query()
|> filter_public_visibility()
|> filter_not_event_uuid(not_event_uuid)
|> filter_draft()
|> Repo.one()
end
@ -223,16 +239,18 @@ defmodule Mobilizon.Events do
"""
@spec create_event(map) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()}
def create_event(attrs \\ %{}) do
with {:ok, %Event{} = event} <- do_create_event(attrs),
with {:ok, %Event{draft: false} = event} <- do_create_event(attrs),
{:ok, %Participant{} = _participant} <-
%Participant{}
|> Participant.changeset(%{
create_participant(%{
actor_id: event.organizer_actor_id,
role: :creator,
event_id: event.id
})
|> Repo.insert() do
}) do
{:ok, event}
else
# We don't create a creator participant if the event is a draft
{:ok, %Event{draft: true} = event} -> {:ok, event}
err -> err
end
end
@ -262,10 +280,24 @@ defmodule Mobilizon.Events do
Updates an event.
"""
@spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Ecto.Changeset.t()}
def update_event(%Event{} = old_event, attrs) do
def update_event(
%Event{draft: old_draft_status, id: event_id, organizer_actor_id: organizer_actor_id} =
old_event,
attrs
) do
with %Ecto.Changeset{changes: changes} = changeset <-
old_event |> Repo.preload(:tags) |> Event.update_changeset(attrs) do
with {:ok, %Event{} = new_event} <- Repo.update(changeset) do
with {:ok, %Event{draft: new_draft_status} = new_event} <- Repo.update(changeset) do
# If the event is no longer a draft
if old_draft_status == true && new_draft_status == false do
{:ok, %Participant{} = _participant} =
create_participant(%{
event_id: event_id,
role: :creator,
actor_id: organizer_actor_id
})
end
Mobilizon.Service.Events.Tool.calculate_event_diff_and_send_notifications(
old_event,
new_event,
@ -309,6 +341,7 @@ defmodule Mobilizon.Events do
|> sort(sort, direction)
|> filter_future_events(is_future)
|> filter_unlisted(is_unlisted)
|> filter_draft()
|> Repo.all()
end
@ -320,6 +353,7 @@ defmodule Mobilizon.Events do
tags
|> Enum.map(& &1.id)
|> events_by_tags_query(limit)
|> filter_draft()
|> Repo.all()
end
@ -333,6 +367,7 @@ defmodule Mobilizon.Events do
actor_id
|> event_for_actor_query()
|> filter_public_visibility()
|> filter_draft()
|> preload_for_event()
|> Page.paginate(page, limit)
|> Repo.all()
@ -345,6 +380,15 @@ defmodule Mobilizon.Events do
{:ok, events, events_count}
end
@spec list_drafts_for_user(integer, integer | nil, integer | nil) :: [Event.t()]
def list_drafts_for_user(user_id, page \\ nil, limit \\ nil) do
Event
|> user_events_query(user_id)
|> filter_draft(true)
|> Page.paginate(page, limit)
|> Repo.all()
end
@doc """
Finds close events to coordinates.
Radius is in meters and defaults to 50km.
@ -354,6 +398,7 @@ defmodule Mobilizon.Events do
"SRID=#{srid};POINT(#{lon} #{lat})"
|> Geo.WKT.decode!()
|> close_events_query(radius)
|> filter_draft()
|> Repo.all()
end
@ -364,6 +409,7 @@ defmodule Mobilizon.Events do
def count_local_events do
count_local_events_query()
|> filter_public_visibility()
|> filter_draft()
|> Repo.one()
end
@ -1134,6 +1180,16 @@ defmodule Mobilizon.Events do
)
end
@spec user_events_query(Ecto.Query.t(), number()) :: Ecto.Query.t()
defp user_events_query(query, user_id) do
from(
e in query,
join: a in Actor,
on: a.id == e.organizer_actor_id,
where: a.user_id == ^user_id
)
end
@spec events_by_name_query(String.t()) :: Ecto.Query.t()
defp events_by_name_query(name) do
from(
@ -1372,6 +1428,11 @@ defmodule Mobilizon.Events do
from(e in query, where: e.uuid != ^not_event_uuid)
end
@spec filter_draft(Ecto.Query.t(), boolean) :: Ecto.Query.t()
defp filter_draft(query, is_draft \\ false) do
from(e in query, where: e.draft == ^is_draft)
end
@spec filter_future_events(Ecto.Query.t(), boolean) :: Ecto.Query.t()
defp filter_future_events(query, true) do
from(q in query, where: q.begins_on > ^DateTime.utc_now())

View File

@ -31,7 +31,8 @@ defmodule MobilizonWeb.API.Events do
to: args.to,
actor: organizer_actor,
object: event,
local: true
# For now we don't federate drafts but it will be needed if we want to edit them as groups
local: args.metadata.draft == false
})
end
end
@ -65,7 +66,7 @@ defmodule MobilizonWeb.API.Events do
actor: organizer_actor.url,
cc: [],
object: event,
local: true
local: args.metadata.draft == false
})
end
end
@ -95,7 +96,8 @@ defmodule MobilizonWeb.API.Events do
join_options: Map.get(args, :join_options),
status: Map.get(args, :status),
online_address: Map.get(args, :online_address),
phone_address: Map.get(args, :phone_address)
phone_address: Map.get(args, :phone_address),
draft: Map.get(args, :draft)
}
}
end

View File

@ -31,6 +31,20 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, :events_max_limit_reached}
end
def find_event(
_parent,
%{uuid: uuid},
%{context: %{current_user: %User{id: user_id}}} = _resolution
) do
case {:has_event, Mobilizon.Events.get_own_event_by_uuid_with_preload(uuid, user_id)} do
{:has_event, %Event{} = event} ->
{:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))}
{:has_event, _} ->
{:error, "Event with UUID #{uuid} not found"}
end
end
def find_event(_parent, %{uuid: uuid}, _resolution) do
case {:has_event, Mobilizon.Events.get_public_event_by_uuid_with_preload(uuid)} do
{:has_event, %Event{} = event} ->
@ -264,6 +278,9 @@ defmodule MobilizonWeb.Resolvers.Event do
else
{:is_owned, nil} ->
{:error, "Organizer actor id is not owned by the user"}
{:error, %Ecto.Changeset{} = error} ->
{:error, error}
end
end

View File

@ -243,6 +243,19 @@ defmodule MobilizonWeb.Resolvers.User do
end
end
@doc """
Returns the list of draft events for the current user
"""
def user_drafted_events(%User{id: user_id}, args, %{
context: %{current_user: %User{id: logged_user_id}}
}) do
with {:same_user, true} <- {:same_user, user_id == logged_user_id},
events <-
Events.list_drafts_for_user(user_id, Map.get(args, :page), Map.get(args, :limit)) do
{:ok, events}
end
end
def change_password(_parent, %{old_password: old_password, new_password: new_password}, %{
context: %{current_user: %User{password_hash: old_password_hash} = user}
}) do

View File

@ -6,6 +6,7 @@ defmodule MobilizonWeb.Schema.EventType do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import MobilizonWeb.Schema.Utils
alias Mobilizon.{Actors, Addresses}
@ -60,6 +61,8 @@ defmodule MobilizonWeb.Schema.EventType do
field(:category, :string, description: "The event's category")
field(:draft, :boolean, description: "Whether or not the event is a draft")
field(:participant_stats, :participant_stats, resolve: &Event.stats_participants_for_event/3)
field(:participants, list_of(:participant), description: "The event's participants") do
@ -252,8 +255,9 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:category, :string, default_value: "meeting")
arg(:physical_address, :address_input)
arg(:options, :event_options_input)
arg(:draft, :boolean, default_value: false)
resolve(&Event.create_event/3)
resolve(handle_errors(&Event.create_event/3))
end
@desc "Update an event"
@ -280,8 +284,9 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:category, :string)
arg(:physical_address, :address_input)
arg(:options, :event_options_input)
arg(:draft, :boolean)
resolve(&Event.update_event/3)
resolve(handle_errors(&Event.update_event/3))
end
@desc "Delete an event"

View File

@ -49,7 +49,7 @@ defmodule MobilizonWeb.Schema.UserType do
field(:locale, :string, description: "The user's locale")
field(:participations, list_of(:participant),
description: "The list of events this user goes to"
description: "The list of participations this user has"
) do
arg(:after_datetime, :datetime)
arg(:before_datetime, :datetime)
@ -57,6 +57,12 @@ defmodule MobilizonWeb.Schema.UserType do
arg(:limit, :integer, default_value: 10)
resolve(&User.user_participations/3)
end
field(:drafts, list_of(:event), description: "The list of draft events this user has created") do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&User.user_drafted_events/3)
end
end
enum :user_role do

View File

@ -70,6 +70,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
"status" => object["status"],
"online_address" => object["onlineAddress"],
"phone_address" => object["phoneAddress"],
"draft" => object["draft"] || false,
"url" => object["id"],
"uuid" => object["uuid"],
"tags" => tags,
@ -111,6 +112,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
"joinOptions" => to_string(event.join_options),
"endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> build_tags(),
"draft" => event.draft,
"id" => event.url,
"url" => event.url
}

View File

@ -319,6 +319,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"status" => metadata.status,
"onlineAddress" => metadata.online_address,
"phoneAddress" => metadata.phone_address,
"draft" => metadata.draft,
"uuid" => uuid,
"tag" =>
tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} end)

View File

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddDraftFlagToEvents do
use Ecto.Migration
def change do
alter table(:events) do
add(:draft, :boolean, default: false)
end
end
end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api
# timestamp: Mon Sep 30 2019 09:56:05 GMT+0200 (GMT+02:00)
# timestamp: Wed Oct 02 2019 16:30:43 GMT+0200 (GMT+02:00)
schema {
query: RootQueryType
@ -264,6 +264,9 @@ type Event implements ActionLogObject {
"""The event's description"""
description: String
"""Whether or not the event is a draft"""
draft: Boolean
"""Datetime for when the event ends"""
endsOn: DateTime
@ -897,6 +900,7 @@ type RootMutationType {
beginsOn: DateTime!
category: String = "meeting"
description: String!
draft: Boolean = false
endsOn: DateTime
joinOptions: EventJoinOptions = FREE
onlineAddress: String
@ -973,7 +977,7 @@ type RootMutationType {
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
"""Create an user"""
createUser(email: String!, password: String!): User
createUser(email: String!, locale: String, password: String!): User
"""Delete an event"""
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
@ -1030,19 +1034,20 @@ type RootMutationType {
): Person
"""Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String = "en"): String
resendConfirmationEmail(email: String!, locale: String): String
"""Reset user password"""
resetPassword(locale: String = "en", password: String!, token: String!): Login
"""Send a link through email to reset user password"""
sendResetPassword(email: String!, locale: String = "en"): String
sendResetPassword(email: String!, locale: String): String
"""Update an event"""
updateEvent(
beginsOn: DateTime
category: String
description: String
draft: Boolean
endsOn: DateTime
eventId: ID!
joinOptions: EventJoinOptions = FREE
@ -1212,6 +1217,9 @@ type User {
"""The user's default actor"""
defaultActor: Person
"""The list of draft events this user has created"""
drafts(limit: Int = 10, page: Int = 1): [Event]
"""The user's email"""
email: String!
@ -1221,7 +1229,10 @@ type User {
"""The user's ID"""
id: ID!
"""The list of events this user goes to"""
"""The user's locale"""
locale: String
"""The list of participations this user has"""
participations(afterDatetime: DateTime, beforeDatetime: DateTime, limit: Int = 10, page: Int = 1): [Participant]
"""The user's list of profiles (identities)"""

View File

@ -119,6 +119,97 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event"
end
test "create_event/3 creates an event as a draft", %{conn: conn, actor: actor, user: user} do
mutation = """
mutation {
createEvent(
title: "come to my event",
description: "it will be fine",
begins_on: "#{
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}",
organizer_actor_id: "#{actor.id}",
category: "birthday",
draft: true
) {
title,
uuid,
id,
draft
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event"
assert json_response(res, 200)["data"]["createEvent"]["draft"] == true
event_uuid = json_response(res, 200)["data"]["createEvent"]["uuid"]
event_id = json_response(res, 200)["data"]["createEvent"]["id"]
query = """
{
event(uuid: "#{event_uuid}") {
uuid,
draft
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert hd(json_response(res, 200)["errors"])["message"] =~ "not found"
query = """
{
event(uuid: "#{event_uuid}") {
uuid,
draft
}
}
"""
res =
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["event"]["draft"] == true
query = """
{
person(preferredUsername: "#{actor.preferred_username}") {
id,
participations(eventId: #{event_id}) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
"""
res =
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == []
end
test "create_event/3 creates an event with options", %{conn: conn, actor: actor, user: user} do
begins_on = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
ends_on = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
@ -684,6 +775,157 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
"picture for my event"
end
test "update_event/3 respects the draft status", %{conn: conn, actor: actor, user: user} do
event = insert(:event, organizer_actor: actor, draft: true)
mutation = """
mutation {
updateEvent(
event_id: #{event.id},
title: "my event updated but still draft"
) {
draft,
title,
uuid
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["data"]["updateEvent"]["draft"] == true
query = """
{
event(uuid: "#{event.uuid}") {
uuid,
draft
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert hd(json_response(res, 200)["errors"])["message"] =~ "not found"
query = """
{
event(uuid: "#{event.uuid}") {
uuid,
draft
}
}
"""
res =
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["event"]["draft"] == true
query = """
{
person(preferredUsername: "#{actor.preferred_username}") {
id,
participations(eventId: #{event.id}) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
"""
res =
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == []
mutation = """
mutation {
updateEvent(
event_id: #{event.id},
title: "my event updated and no longer draft",
draft: false
) {
draft,
title,
uuid
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["data"]["updateEvent"]["draft"] == false
query = """
{
event(uuid: "#{event.uuid}") {
uuid,
draft
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["event"]["draft"] == false
query = """
{
person(preferredUsername: "#{actor.preferred_username}") {
id,
participations(eventId: #{event.id}) {
role,
actor {
id
},
event {
id
}
}
}
}
"""
res =
conn
|> auth_conn(user)
|> get("/api", AbsintheHelpers.query_skeleton(query, "person"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["person"]["participations"] == [
%{
"actor" => %{"id" => to_string(actor.id)},
"event" => %{"id" => to_string(event.id)},
"role" => "CREATOR"
}
]
end
test "list_events/3 returns events", context do
event = insert(:event)
@ -782,6 +1024,24 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == []
end
test "list_events/3 doesn't list draft events", context do
insert(:event, visibility: :public, draft: true)
query = """
{
events {
uuid,
}
}
"""
res =
context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
assert json_response(res, 200)["data"]["events"] |> Enum.map(& &1["uuid"]) == []
end
test "find_event/3 returns an unlisted event", context do
event = insert(:event, visibility: :unlisted)