Merge branch 'improve-form' into 'master'

Improve form

See merge request framasoft/mobilizon!175
This commit is contained in:
Thomas Citharel 2019-09-03 08:56:58 +02:00
commit 0cf9b0b01f
19 changed files with 876 additions and 507 deletions

View file

@ -59,7 +59,7 @@
"graphql-cli": "^3.0.12", "graphql-cli": "^3.0.12",
"node-sass": "^4.11.0", "node-sass": "^4.11.0",
"patch-package": "^6.1.2", "patch-package": "^6.1.2",
"sass-loader": "^7.1.0", "sass-loader": "^8.0.0",
"tslint": "^5.16.0", "tslint": "^5.16.0",
"tslint-config-airbnb": "^5.11.1", "tslint-config-airbnb": "^5.11.1",
"typescript": "^3.4.3", "typescript": "^3.4.3",

View file

@ -99,6 +99,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/taginput"; @import "~buefy/src/scss/components/taginput";
@import "~buefy/src/scss/components/upload"; @import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/components/radio"; @import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch";
.router-enter-active, .router-enter-active,
.router-leave-active { .router-leave-active {

View file

@ -460,18 +460,6 @@ export default class CreateEvent extends Vue {
margin-bottom: 1rem; margin-bottom: 1rem;
transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s; transition: visibility 0.2s 0.4s, opacity 0.2s 0.4s;
&.bar-is-hidden {
visibility: hidden;
opacity: 0;
}
&.is-focused {
visibility: visible;
opacity: 1;
height: auto;
transition: visibility 0.2s, opacity 0.2s;
}
&__button { &__button {
font-weight: bold; font-weight: bold;
display: inline-flex; display: inline-flex;
@ -510,10 +498,14 @@ export default class CreateEvent extends Vue {
div.ProseMirror { div.ProseMirror {
min-height: 10rem; min-height: 10rem;
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
background-color: white;
border-radius: 4px;
color: #363636;
border: 1px solid #dbdbdb;
&:focus { &:focus {
border-color: #3273dc;
background: #fff;
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25);
} }
} }

View file

@ -146,6 +146,7 @@ export const CREATE_EVENT = gql`
$beginsOn: DateTime!, $beginsOn: DateTime!,
$picture: PictureInput, $picture: PictureInput,
$tags: [String], $tags: [String],
$options: EventOptionsInput,
$physicalAddress: AddressInput, $physicalAddress: AddressInput,
$visibility: EventVisibility $visibility: EventVisibility
) { ) {
@ -155,6 +156,7 @@ export const CREATE_EVENT = gql`
beginsOn: $beginsOn, beginsOn: $beginsOn,
organizerActorId: $organizerActorId, organizerActorId: $organizerActorId,
category: $category, category: $category,
options: $options,
picture: $picture, picture: $picture,
tags: $tags, tags: $tags,
physicalAddress: $physicalAddress, physicalAddress: $physicalAddress,

View file

@ -22,6 +22,12 @@ export enum EventJoinOptions {
INVITE, INVITE,
} }
export enum EventVisibilityJoinOptions {
PUBLIC = 'PUBLIC',
LINK = 'LINK',
LIMITED = 'LIMITED',
}
export enum ParticipantRole { export enum ParticipantRole {
NOT_APPROVED = 'not_approved', NOT_APPROVED = 'not_approved',
PARTICIPANT = 'participant', PARTICIPANT = 'participant',
@ -44,6 +50,24 @@ export interface IParticipant {
event: IEvent; event: IEvent;
} }
export interface IOffer {
price: number;
priceCurrency: string;
url: string;
}
export interface IParticipationCondition {
title: string;
content: string;
url: string;
}
export enum CommentModeration {
ALLOW_ALL = 'ALLOW_ALL',
MODERATED = 'MODERATED',
CLOSED = 'CLOSED',
}
export interface IEvent { export interface IEvent {
id?: number; id?: number;
uuid: string; uuid: string;
@ -77,6 +101,31 @@ export interface IEvent {
physicalAddress?: IAddress; physicalAddress?: IAddress;
tags: ITag[]; tags: ITag[];
options: IEventOptions;
}
export interface IEventOptions {
maximumAttendeeCapacity: number;
remainingAttendeeCapacity: number;
showRemainingAttendeeCapacity: boolean;
offers: IOffer[];
participationConditions: IParticipationCondition[];
attendees: string[];
program: string;
commentModeration: CommentModeration;
showParticipationPrice: boolean;
}
export class EventOptions implements IEventOptions {
maximumAttendeeCapacity: number = 0;
remainingAttendeeCapacity: number = 0;
showRemainingAttendeeCapacity: boolean = false;
offers: IOffer[] = [];
participationConditions: IParticipationCondition[] = [];
attendees: string[] = [];
program: string = '';
commentModeration: CommentModeration = CommentModeration.ALLOW_ALL;
showParticipationPrice: boolean = false;
} }
export class EventModel implements IEvent { export class EventModel implements IEvent {
@ -113,6 +162,7 @@ export class EventModel implements IEvent {
organizerActor = new Actor(); organizerActor = new Actor();
tags: ITag[] = []; tags: ITag[] = [];
options: IEventOptions = new EventOptions();
constructor(hash?: IEvent) { constructor(hash?: IEvent) {
if (!hash) return; if (!hash) return;
@ -150,5 +200,6 @@ export class EventModel implements IEvent {
this.physicalAddress = hash.physicalAddress; this.physicalAddress = hash.physicalAddress;
this.tags = hash.tags; this.tags = hash.tags;
this.options = hash.options;
} }
} }

View file

@ -32,6 +32,10 @@
<editor v-model="event.description" /> <editor v-model="event.description" />
</div> </div>
<b-field :label="$gettext('Website / URL')">
<b-input v-model="event.onlineAddress" placeholder="URL" />
</b-field>
<!--<b-field :label="$gettext('Category')"> <!--<b-field :label="$gettext('Category')">
<b-select placeholder="Select a category" v-model="event.category"> <b-select placeholder="Select a category" v-model="event.category">
<option <option
@ -44,23 +48,124 @@
<h2 class="subtitle"> <h2 class="subtitle">
<translate> <translate>
Visibility Who can view this event and participate
</translate> </translate>
</h2> </h2>
<label class="label">{{ $gettext('Event visibility') }}</label>
<div class="field"> <div class="field">
<b-radio v-model="event.visibility" name="name" :native-value="EventVisibility.PUBLIC"> <b-radio v-model="eventVisibilityJoinOptions"
name="eventVisibilityJoinOptions"
:native-value="EventVisibilityJoinOptions.PUBLIC">
<translate>Visible everywhere on the web (public)</translate> <translate>Visible everywhere on the web (public)</translate>
</b-radio> </b-radio>
</div> </div>
<div class="field"> <div class="field">
<b-radio v-model="event.visibility" name="name" :native-value="EventVisibility.PRIVATE"> <b-radio v-model="eventVisibilityJoinOptions"
name="eventVisibilityJoinOptions"
:native-value="EventVisibilityJoinOptions.LINK">
<translate>Only accessible through link and search (private)</translate> <translate>Only accessible through link and search (private)</translate>
</b-radio> </b-radio>
</div> </div>
<div class="field">
<b-radio v-model="eventVisibilityJoinOptions"
name="eventVisibilityJoinOptions"
:native-value="EventVisibilityJoinOptions.LIMITED">
<translate>Page limited to my group (asks for auth)</translate>
</b-radio>
</div>
<div class="field">
<label class="label">Approbation des participations</label>
<b-switch v-model="needsApproval">
Je veux approuver chaque demande de participation
</b-switch>
</div>
<div class="field">
<label class="label">Mise en avant</label>
<b-switch v-model="doNotPromote" :disabled="canPromote === false">
Ne pas autoriser la mise en avant sur sur Mobilizon
</b-switch>
</div>
<div class="field">
<b-switch v-model="limitedPlaces">
Places limitées
</b-switch>
</div>
<div class="box" v-if="limitedPlaces">
<b-field label="Number of places">
<b-numberinput v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
</b-field>
<b-field>
<b-switch v-model="event.options.showRemainingAttendeeCapacity">
Show remaining number of places
</b-switch>
</b-field>
<b-field>
<b-switch v-model="event.options.showParticipationPrice">
Display participation price
</b-switch>
</b-field>
</div>
<h2 class="subtitle">
<translate>
Modération des commentaires publics
</translate>
</h2>
<label>Comments on the event page</label>
<div class="field">
<b-radio v-model="event.options.commentModeration"
name="commentModeration"
:native-value="CommentModeration.ALLOW_ALL">
<translate>Allow all comments</translate>
</b-radio>
</div>
<div class="field">
<b-radio v-model="event.options.commentModeration"
name="commentModeration"
:native-value="CommentModeration.MODERATED">
<translate>Moderated comments (shown after approval)</translate>
</b-radio>
</div>
<div class="field">
<b-radio v-model="event.options.commentModeration"
name="commentModeration"
:native-value="CommentModeration.CLOSED">
<translate>Close all comments (except for admins)</translate>
</b-radio>
</div>
<h2 class="subtitle">
<translate>
Status
</translate>
</h2>
<div class="field">
<b-radio v-model="event.status"
name="status"
:native-value="EventStatus.TENTATIVE">
<translate>Tentative: Will be confirmed later</translate>
</b-radio>
</div>
<div class="field">
<b-radio v-model="event.status"
name="status"
:native-value="EventStatus.CONFIRMED">
<translate>Confirmed: Will happen</translate>
</b-radio>
</div>
<button class="button is-primary"> <button class="button is-primary">
<translate v-if="isUpdate === false">Create my event</translate> <translate v-if="isUpdate === false">Create my event</translate>
<translate v-else>Update my event</translate> <translate v-else>Update my event</translate>
</button> </button>
@ -72,7 +177,7 @@
<script lang="ts"> <script lang="ts">
import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT } from '@/graphql/event'; import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT } from '@/graphql/event';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { EventModel, EventVisibility, IEvent } from '@/types/event.model'; import { EventModel, EventStatus, EventVisibility, EventVisibilityJoinOptions, IEvent, CommentModeration } from '@/types/event.model';
import { LOGGED_PERSON } from '@/graphql/actor'; import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor'; import { IPerson, Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue'; import PictureUpload from '@/components/PictureUpload.vue';
@ -105,7 +210,14 @@ export default class EditEvent extends Vue {
event = new EventModel(); event = new EventModel();
pictureFile: File | null = null; pictureFile: File | null = null;
EventVisibility = EventVisibility; EventStatus = EventStatus;
EventVisibilityJoinOptions = EventVisibilityJoinOptions;
eventVisibilityJoinOptions: EventVisibilityJoinOptions = EventVisibilityJoinOptions.PUBLIC;
needsApproval: boolean = false;
doNotPromote: boolean = false;
canPromote: boolean = true;
limitedPlaces: boolean = false;
CommentModeration = CommentModeration;
// categories: string[] = Object.keys(Category); // categories: string[] = Object.keys(Category);
@ -129,6 +241,7 @@ export default class EditEvent extends Vue {
this.event.beginsOn = now; this.event.beginsOn = now;
this.event.endsOn = end; this.event.endsOn = end;
console.log('eventvisibilityjoinoptions', this.eventVisibilityJoinOptions);
} }
createOrUpdate(e: Event) { createOrUpdate(e: Event) {
@ -141,7 +254,7 @@ export default class EditEvent extends Vue {
async createEvent() { async createEvent() {
try { try {
const data = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: CREATE_EVENT, mutation: CREATE_EVENT,
variables: this.buildVariables(), variables: this.buildVariables(),
}); });
@ -204,6 +317,26 @@ export default class EditEvent extends Vue {
return new EventModel(result.data.event); return new EventModel(result.data.event);
} }
@Watch('eventVisibilityJoinOptions')
calculateVisibilityAndJoinOptions(eventVisibilityJoinOptions) {
switch (eventVisibilityJoinOptions) {
case EventVisibilityJoinOptions.PUBLIC:
this.event.visibility = EventVisibility.UNLISTED;
this.canPromote = true;
break;
case EventVisibilityJoinOptions.LINK:
this.event.visibility = EventVisibility.PRIVATE;
this.canPromote = false;
this.doNotPromote = false;
break;
case EventVisibilityJoinOptions.LIMITED:
this.event.visibility = EventVisibility.RESTRICTED;
this.canPromote = false;
this.doNotPromote = false;
break;
}
}
// getAddressData(addressData) { // getAddressData(addressData) {
// if (addressData !== null) { // if (addressData !== null) {
// this.event.address = { // this.event.address = {
@ -224,3 +357,16 @@ export default class EditEvent extends Vue {
// } // }
} }
</script> </script>
<style lang="scss">
@import "@/variables.scss";
h2.subtitle {
margin: 10px 0;
span {
padding: 5px 7px;
display: inline;
background: $secondary;
}
}
</style>

View file

@ -289,7 +289,7 @@ export default class Event extends Vue {
}, },
}); });
router.push({ name: RouteName.EVENT }); await router.push({ name: RouteName.EVENT });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -297,22 +297,25 @@ export default class Event extends Vue {
async joinEvent() { async joinEvent() {
try { try {
await this.$apollo.mutate<IParticipant>({ await this.$apollo.mutate<{ joinEvent: IParticipant }>({
mutation: JOIN_EVENT, mutation: JOIN_EVENT,
variables: { variables: {
eventId: this.event.id, eventId: this.event.id,
actorId: this.loggedPerson.id, actorId: this.loggedPerson.id,
}, },
update: (store, { data: { joinEvent } }) => { update: (store, { data }) => {
const event = store.readQuery<IEvent>({ query: FETCH_EVENT }); 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) { if (event === null) {
console.error('Cannot update event participant cache, because of null value.'); console.error('Cannot update event participant cache, because of null value.');
return; return;
} }
event.participants = event.participants.concat([joinEvent]); event.participants = event.participants.concat([data.joinEvent]);
store.writeQuery({ query: FETCH_EVENT, data: event }); store.writeQuery({ query: FETCH_EVENT, data: { event } });
}, },
}); });
} catch (error) { } catch (error) {
@ -322,23 +325,26 @@ export default class Event extends Vue {
async leaveEvent() { async leaveEvent() {
try { try {
await this.$apollo.mutate<IParticipant>({ await this.$apollo.mutate<{ leaveEvent: IParticipant }>({
mutation: LEAVE_EVENT, mutation: LEAVE_EVENT,
variables: { variables: {
eventId: this.event.id, eventId: this.event.id,
actorId: this.loggedPerson.id, actorId: this.loggedPerson.id,
}, },
update: (store, { data: { leaveEvent } }) => { update: (store, { data }) => {
const event = store.readQuery<IEvent>({ query: FETCH_EVENT }); 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) { if (event === null) {
console.error('Cannot update event participant cache, because of null value.'); console.error('Cannot update event participant cache, because of null value.');
return; return;
} }
event.participants = event.participants event.participants = event.participants
.filter(p => p.actor.id !== leaveEvent.actor.id); .filter(p => p.actor.id !== data.leaveEvent.actor.id);
store.writeQuery({ query: FETCH_EVENT, data: event }); store.writeQuery({ query: FETCH_EVENT, data: { event } });
}, },
}); });
} catch (error) { } catch (error) {

View file

@ -124,20 +124,23 @@ export default class Login extends Vue {
this.errors = []; this.errors = [];
try { try {
const result = await this.$apollo.mutate<{ login: ILogin }>({ const { data } = await this.$apollo.mutate<{ login: ILogin }>({
mutation: LOGIN, mutation: LOGIN,
variables: { variables: {
email: this.credentials.email, email: this.credentials.email,
password: this.credentials.password, password: this.credentials.password,
}, },
}); });
if (data == null) {
throw new Error('Data is undefined');
}
saveUserData(result.data.login); saveUserData(data.login);
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT, mutation: UPDATE_CURRENT_USER_CLIENT,
variables: { variables: {
id: result.data.login.user.id, id: data.login.user.id,
email: this.credentials.email, email: this.credentials.email,
isLoggedIn: true, isLoggedIn: true,
}, },

View file

@ -71,16 +71,19 @@ export default class PasswordReset extends Vue {
this.errors.splice(0); this.errors.splice(0);
try { try {
const result = await this.$apollo.mutate<{ resetPassword: ILogin }>({ const { data } = await this.$apollo.mutate<{ resetPassword: ILogin }>({
mutation: RESET_PASSWORD, mutation: RESET_PASSWORD,
variables: { variables: {
password: this.credentials.password, password: this.credentials.password,
token: this.token, token: this.token,
}, },
}); });
if (data == null) {
throw new Error('Data is undefined');
}
saveUserData(result.data.resetPassword); saveUserData(data.resetPassword);
this.$router.push({ name: RouteName.HOME }); await this.$router.push({ name: RouteName.HOME });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
err.graphQLErrors.forEach(({ message }) => { err.graphQLErrors.forEach(({ message }) => {

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@ defmodule Mobilizon.Events.Event do
field(:online_address, :string) field(:online_address, :string)
field(:phone_address, :string) field(:phone_address, :string)
field(:category, :string) field(:category, :string)
embeds_one(:options, Mobilizon.Events.EventOptions)
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id) belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
many_to_many(:tags, Tag, join_through: "events_tags") many_to_many(:tags, Tag, join_through: "events_tags")
@ -87,6 +88,7 @@ defmodule Mobilizon.Events.Event do
:picture_id, :picture_id,
:physical_address_id :physical_address_id
]) ])
|> cast_embed(:options)
|> validate_required([ |> validate_required([
:title, :title,
:begins_on, :begins_on,

View file

@ -0,0 +1,69 @@
import EctoEnum
defenum(Mobilizon.Events.CommentModeration, :comment_moderation, [:allow_all, :moderated, :closed])
defmodule Mobilizon.Events.EventOffer do
@moduledoc """
Represents an event offer
"""
use Ecto.Schema
embedded_schema do
field(:price, :float)
field(:price_currency, :string)
field(:url, :string)
end
end
defmodule Mobilizon.Events.EventParticipationCondition do
@moduledoc """
Represents an event participation condition
"""
use Ecto.Schema
embedded_schema do
field(:title, :string)
field(:content, :string)
field(:url, :string)
end
end
defmodule Mobilizon.Events.EventOptions do
@moduledoc """
Represents an event options
"""
use Ecto.Schema
alias Mobilizon.Events.{
EventOptions,
EventOffer,
EventParticipationCondition,
CommentModeration
}
@primary_key false
embedded_schema do
field(:maximum_attendee_capacity, :integer)
field(:remaining_attendee_capacity, :integer)
field(:show_remaining_attendee_capacity, :boolean)
embeds_many(:offers, EventOffer)
embeds_many(:participation_condition, EventParticipationCondition)
field(:attendees, {:array, :string})
field(:program, :string)
field(:comment_moderation, CommentModeration)
field(:show_participation_price, :boolean)
end
def changeset(%EventOptions{} = event_options, attrs) do
event_options
|> Ecto.Changeset.cast(attrs, [
:maximum_attendee_capacity,
:remaining_attendee_capacity,
:show_remaining_attendee_capacity,
:attendees,
:program,
:comment_moderation,
:show_participation_price
])
end
end

View file

@ -14,14 +14,15 @@ defmodule MobilizonWeb.API.Events do
@spec create_event(map()) :: {:ok, Activity.t(), Event.t()} | any() @spec create_event(map()) :: {:ok, Activity.t(), Event.t()} | any()
def create_event( def create_event(
%{ %{
title: title,
description: description,
organizer_actor_id: organizer_actor_id,
begins_on: begins_on, begins_on: begins_on,
category: category, description: description,
tags: tags options: options,
organizer_actor_id: organizer_actor_id,
tags: tags,
title: title
} = args } = args
) do )
when is_map(options) do
with %Actor{url: url} = actor <- with %Actor{url: url} = actor <-
Actors.get_local_actor_with_everything(organizer_actor_id), Actors.get_local_actor_with_everything(organizer_actor_id),
physical_address <- Map.get(args, :physical_address, nil), physical_address <- Map.get(args, :physical_address, nil),
@ -38,7 +39,12 @@ defmodule MobilizonWeb.API.Events do
content_html, content_html,
picture, picture,
tags, tags,
%{begins_on: begins_on, physical_address: physical_address, category: category} %{
begins_on: begins_on,
physical_address: physical_address,
category: Map.get(args, :category),
options: options
}
) do ) do
ActivityPub.create(%{ ActivityPub.create(%{
to: ["https://www.w3.org/ns/activitystreams#Public"], to: ["https://www.w3.org/ns/activitystreams#Public"],

View file

@ -68,6 +68,7 @@ defmodule MobilizonWeb.Schema.EventType do
field(:updated_at, :datetime, description: "When the event was last updated") field(:updated_at, :datetime, description: "When the event was last updated")
field(:created_at, :datetime, description: "When the event was created") field(:created_at, :datetime, description: "When the event was created")
field(:options, :event_options, description: "The event options")
end end
@desc "The list of visibility options for an event" @desc "The list of visibility options for an event"
@ -90,6 +91,101 @@ defmodule MobilizonWeb.Schema.EventType do
value(:cancelled, description: "The event is cancelled") value(:cancelled, description: "The event is cancelled")
end end
object :event_offer do
field(:price, :float, description: "The price amount for this offer")
field(:price_currency, :string, description: "The currency for this price offer")
field(:url, :string, description: "The URL to access to this offer")
end
object :event_participation_condition do
field(:title, :string, description: "The title for this condition")
field(:content, :string, description: "The content for this condition")
field(:url, :string, description: "The URL to access this condition")
end
input_object :event_offer_input do
field(:price, :float, description: "The price amount for this offer")
field(:price_currency, :string, description: "The currency for this price offer")
field(:url, :string, description: "The URL to access to this offer")
end
input_object :event_participation_condition_input do
field(:title, :string, description: "The title for this condition")
field(:content, :string, description: "The content for this condition")
field(:url, :string, description: "The URL to access this condition")
end
@desc "The list of possible options for the event's status"
enum :event_comment_moderation do
value(:allow_all, description: "Anyone can comment under the event")
value(:moderated, description: "Every comment has to be moderated by the admin")
value(:closed, description: "No one can comment except for the admin")
end
object :event_options do
field(:maximum_attendee_capacity, :integer,
description: "The maximum attendee capacity for this event"
)
field(:remaining_attendee_capacity, :integer,
description: "The number of remaining seats for this event"
)
field(:show_remaining_attendee_capacity, :boolean,
description: "Whether or not to show the number of remaining seats for this event"
)
field(:offers, list_of(:event_offer), description: "The list of offers to show for this event")
field(:participation_conditions, list_of(:event_participation_condition),
description: "The list of participation conditions to accept to join this event"
)
field(:attendees, list_of(:string), description: "The list of special attendees")
field(:program, :string, description: "The list of the event")
field(:comment_moderation, :event_comment_moderation,
description: "The policy on public comment moderation under the event"
)
field(:show_participation_price, :boolean,
description: "Whether or not to show the participation price"
)
end
input_object :event_options_input do
field(:maximum_attendee_capacity, :integer,
description: "The maximum attendee capacity for this event"
)
field(:remaining_attendee_capacity, :integer,
description: "The number of remaining seats for this event"
)
field(:show_remaining_attendee_capacity, :boolean,
description: "Whether or not to show the number of remaining seats for this event"
)
field(:offers, list_of(:event_offer_input),
description: "The list of offers to show for this event"
)
field(:participation_conditions, list_of(:event_participation_condition_input),
description: "The list of participation conditions to accept to join this event"
)
field(:attendees, list_of(:string), description: "The list of special attendees")
field(:program, :string, description: "The list of the event")
field(:comment_moderation, :event_comment_moderation,
description: "The policy on public comment moderation under the event"
)
field(:show_participation_price, :boolean,
description: "Whether or not to show the participation price"
)
end
object :event_queries do object :event_queries do
@desc "Get all events" @desc "Get all events"
field :events, list_of(:event) do field :events, list_of(:event) do
@ -131,8 +227,9 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:online_address, :string) arg(:online_address, :string)
arg(:phone_address, :string) arg(:phone_address, :string)
arg(:organizer_actor_id, non_null(:id)) arg(:organizer_actor_id, non_null(:id))
arg(:category, :string) arg(:category, :string, default_value: "meeting")
arg(:physical_address, :address_input) arg(:physical_address, :address_input)
arg(:options, :event_options_input, default_value: %{})
resolve(&Event.create_event/3) resolve(&Event.create_event/3)
end end

View file

@ -34,7 +34,8 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
{:actor, Actors.get_actor_by_url(object["actor"])}, {:actor, Actors.get_actor_by_url(object["actor"])},
{:address, address_id} <- {:address, address_id} <-
{:address, get_address(object["location"])}, {:address, get_address(object["location"])},
{:tags, tags} <- {:tags, fetch_tags(object["tag"])} do {:tags, tags} <- {:tags, fetch_tags(object["tag"])},
{:options, options} <- {:options, get_options(object)} do
picture_id = picture_id =
with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0, with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0,
%Picture{id: picture_id} <- %Picture{id: picture_id} <-
@ -50,8 +51,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
_ -> nil _ -> nil
end end
{:ok, entity = %{
%{
"title" => object["name"], "title" => object["name"],
"description" => object["content"], "description" => object["content"],
"organizer_actor_id" => actor_id, "organizer_actor_id" => actor_id,
@ -62,13 +62,30 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
"uuid" => object["uuid"], "uuid" => object["uuid"],
"tags" => tags, "tags" => tags,
"physical_address_id" => address_id "physical_address_id" => address_id
}} }
{:ok, Map.put(entity, "options", options)}
else else
err -> err ->
{:error, err} {:error, err}
end end
end end
# Get only elements that we have in EventOptions
defp get_options(object) do
keys =
Mobilizon.Events.EventOptions
|> struct
|> Map.keys()
|> List.delete(:__struct__)
|> Enum.map(&Utils.camelize/1)
Enum.reduce(object, %{}, fn {key, value}, acc ->
(value && key in keys && Map.put(acc, Utils.underscore(key), value)) ||
acc
end)
end
defp get_address(address_url) when is_bitstring(address_url) do defp get_address(address_url) when is_bitstring(address_url) do
get_address(%{"id" => address_url}) get_address(%{"id" => address_url})
end end

View file

@ -298,7 +298,19 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
do: res, do: res,
else: Map.put(res, "location", make_address_data(metadata.physical_address)) else: Map.put(res, "location", make_address_data(metadata.physical_address))
res =
if is_nil(picture), do: res, else: Map.put(res, "attachment", [make_picture_data(picture)]) if is_nil(picture), do: res, else: Map.put(res, "attachment", [make_picture_data(picture)])
if is_nil(metadata.options) do
res
else
options = struct(Mobilizon.Events.EventOptions, metadata.options) |> Map.from_struct()
Enum.reduce(options, res, fn {key, value}, acc ->
(value && Map.put(acc, camelize(key), value)) ||
acc
end)
end
end end
def make_address_data(%Address{} = address) do def make_address_data(%Address{} = address) do
@ -669,4 +681,21 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key) public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key)
:public_key.pem_encode([public_key]) :public_key.pem_encode([public_key])
end end
def camelize(word) when is_atom(word) do
camelize(to_string(word))
end
def camelize(word) when is_bitstring(word) do
{first, rest} = String.split_at(Macro.camelize(word), 1)
String.downcase(first) <> rest
end
def underscore(word) when is_atom(word) do
underscore(to_string(word))
end
def underscore(word) when is_bitstring(word) do
Macro.underscore(word)
end
end end

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Repo.Migrations.AddOptionsToEvent do
use Ecto.Migration
def change do
alter table(:events) do
add(:options, :map)
end
end
end

View file

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # source: http://localhost:4000/api
# timestamp: Thu Aug 22 2019 12:34:52 GMT+0200 (GMT+02:00) # timestamp: Mon Sep 02 2019 16:41:17 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -245,6 +245,9 @@ type Event {
"""Online address of the event""" """Online address of the event"""
onlineAddress: OnlineAddress onlineAddress: OnlineAddress
"""The event options"""
options: EventOptions
"""The event's organizer (as a person)""" """The event's organizer (as a person)"""
organizerActor: Actor organizerActor: Actor
@ -291,6 +294,120 @@ type Event {
visibility: EventVisibility visibility: EventVisibility
} }
"""The list of possible options for the event's status"""
enum EventCommentModeration {
"""Anyone can comment under the event"""
ALLOW_ALL
"""No one can comment except for the admin"""
CLOSED
"""Every comment has to be moderated by the admin"""
MODERATED
}
type EventOffer {
"""The price amount for this offer"""
price: Float
"""The currency for this price offer"""
priceCurrency: String
"""The URL to access to this offer"""
url: String
}
input EventOfferInput {
"""The price amount for this offer"""
price: Float
"""The currency for this price offer"""
priceCurrency: String
"""The URL to access to this offer"""
url: String
}
type EventOptions {
"""The list of special attendees"""
attendees: [String]
"""The policy on public comment moderation under the event"""
commentModeration: EventCommentModeration
"""The maximum attendee capacity for this event"""
maximumAttendeeCapacity: Int
"""The list of offers to show for this event"""
offers: [EventOffer]
"""The list of participation conditions to accept to join this event"""
participationConditions: [EventParticipationCondition]
"""The list of the event"""
program: String
"""The number of remaining seats for this event"""
remainingAttendeeCapacity: Int
"""Whether or not to show the participation price"""
showParticipationPrice: Boolean
"""Whether or not to show the number of remaining seats for this event"""
showRemainingAttendeeCapacity: Boolean
}
input EventOptionsInput {
"""The list of special attendees"""
attendees: [String]
"""The policy on public comment moderation under the event"""
commentModeration: EventCommentModeration
"""The maximum attendee capacity for this event"""
maximumAttendeeCapacity: Int
"""The list of offers to show for this event"""
offers: [EventOfferInput]
"""The list of participation conditions to accept to join this event"""
participationConditions: [EventParticipationConditionInput]
"""The list of the event"""
program: String
"""The number of remaining seats for this event"""
remainingAttendeeCapacity: Int
"""Whether or not to show the participation price"""
showParticipationPrice: Boolean
"""Whether or not to show the number of remaining seats for this event"""
showRemainingAttendeeCapacity: Boolean
}
type EventParticipationCondition {
"""The content for this condition"""
content: String
"""The title for this condition"""
title: String
"""The URL to access this condition"""
url: String
}
input EventParticipationConditionInput {
"""The content for this condition"""
content: String
"""The title for this condition"""
title: String
"""The URL to access this condition"""
url: String
}
"""Search events result""" """Search events result"""
type Events { type Events {
"""Event elements""" """Event elements"""
@ -710,6 +827,7 @@ type RootMutationType {
description: String! description: String!
endsOn: DateTime endsOn: DateTime
onlineAddress: String onlineAddress: String
options: EventOptionsInput
organizerActorId: ID! organizerActorId: ID!
phoneAddress: String phoneAddress: String
physicalAddress: AddressInput physicalAddress: AddressInput

View file

@ -84,6 +84,48 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event" assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event"
end end
test "create_event/3 creates an event with options", %{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}",
options: {
maximumAttendeeCapacity: 30,
showRemainingAttendeeCapacity: true
}
) {
title,
uuid,
options {
maximumAttendeeCapacity,
showRemainingAttendeeCapacity
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event"
assert json_response(res, 200)["data"]["createEvent"]["options"]["maximumAttendeeCapacity"] ==
30
assert json_response(res, 200)["data"]["createEvent"]["options"][
"showRemainingAttendeeCapacity"
] == true
end
test "create_event/3 creates an event with tags", %{conn: conn, actor: actor, user: user} do test "create_event/3 creates an event with tags", %{conn: conn, actor: actor, user: user} do
mutation = """ mutation = """
mutation { mutation {