Allow to create an event from a group preconfigured with the organizer

Refactored the organizer-picker components a lot

Close #464

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-03-29 10:33:19 +02:00
parent cde9f8873e
commit 13c8080097
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
8 changed files with 194 additions and 188 deletions

View File

@ -1,11 +1,11 @@
<template>
<div class="list is-hoverable">
<b-radio-button
v-model="currentActor"
v-model="selectedActor"
:native-value="availableActor"
class="list-item"
v-for="availableActor in actualAvailableActors"
:class="{ 'is-active': availableActor.id === currentActor.id }"
:class="{ 'is-active': availableActor.id === selectedActor.id }"
:key="availableActor.id"
>
<div class="media">
@ -31,9 +31,13 @@
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { Component, Prop, Vue } from "vue-property-decorator";
import { IPerson, IActor, Actor } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_MEMBERSHIPS,
} from "@/graphql/actor";
import { Paginate } from "@/types/paginate";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
@ -41,29 +45,37 @@ import { MemberRole } from "@/types/enums";
@Component({
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: (data) => data.person.memberships,
skip() {
return !this.identity.id;
},
query: LOGGED_USER_MEMBERSHIPS,
update: (data) => data.loggedUser.memberships,
},
identities: IDENTITIES,
currentActor: CURRENT_ACTOR_CLIENT,
},
})
export default class OrganizerPicker extends Vue {
@Prop() value!: IActor;
@Prop() identity!: IPerson;
@Prop({ required: false, default: false }) restrictModeratorLevel!: boolean;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
currentActor: IActor = this.value;
currentActor!: IPerson;
get selectedActor(): IActor | undefined {
if (this.value?.id) {
return this.value;
}
if (this.currentActor) {
return this.currentActor;
}
return undefined;
}
set selectedActor(actor: IActor | undefined) {
this.$emit("input", actor);
}
identities: IActor[] = [];
Actor = Actor;
@ -82,14 +94,12 @@ export default class OrganizerPicker extends Vue {
get actualAvailableActors(): IActor[] {
return [
this.identity,
this.currentActor,
...this.identities.filter(
(identity: IActor) => identity.id !== this.currentActor?.id
),
...this.actualMemberships.map((member) => member.parent),
];
}
@Watch("currentActor")
async fetchMembersForGroup(): Promise<void> {
this.$emit("input", this.currentActor);
].filter((elem) => elem);
}
}
</script>

View File

@ -1,30 +1,30 @@
<template>
<div class="organizer-picker">
<div class="organizer-picker" v-if="selectedActor">
<!-- If we have a current actor (inline) -->
<div
v-if="inline && currentActor.id"
v-if="inline && selectedActor.id"
class="inline box"
@click="isComponentModalActive = true"
>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="currentActor.avatar">
<figure class="image is-48x48" v-if="selectedActor.avatar">
<img
class="image is-rounded"
:src="currentActor.avatar.url"
:alt="currentActor.avatar.alt"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content" v-if="currentActor.name">
<p class="is-4">{{ currentActor.name }}</p>
<div class="media-content" v-if="selectedActor.name">
<p class="is-4">{{ selectedActor.name }}</p>
<p class="is-6 has-text-grey">
{{ `@${currentActor.preferredUsername}` }}
{{ `@${selectedActor.preferredUsername}` }}
</p>
</div>
<div class="media-content" v-else>
{{ `@${currentActor.preferredUsername}` }}
{{ `@${selectedActor.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t("Change") }}
@ -33,45 +33,18 @@
</div>
<!-- If we have a current actor -->
<span
v-else-if="currentActor.id"
v-else-if="selectedActor.id"
class="block"
@click="isComponentModalActive = true"
>
<img
class="image is-48x48"
v-if="currentActor.avatar"
:src="currentActor.avatar.url"
:alt="currentActor.avatar.alt"
v-if="selectedActor.avatar"
:src="selectedActor.avatar.url"
:alt="selectedActor.avatar.alt"
/>
<b-icon v-else size="is-large" icon="account-circle" />
</span>
<!-- If we have no current actor -->
<div v-if="groupMemberships.total === 0 || !currentActor.id" class="box">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar">
<img
class="image is-rounded"
:src="identity.avatar.url"
:alt="identity.avatar.alt"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content" v-if="identity.name">
<p class="is-4">{{ identity.name }}</p>
<p class="is-6 has-text-grey">
{{ `@${identity.preferredUsername}` }}
</p>
</div>
<div class="media-content" v-else>
{{ `@${identity.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</b-button>
</div>
</div>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
@ -81,20 +54,15 @@
<div class="columns">
<div class="column">
<organizer-picker
v-model="currentActor"
:identity.sync="identity"
v-model="selectedActor"
@input="relay"
:restrict-moderator-level="true"
/>
</div>
<div class="column">
<div v-if="actorMembersForCurrentActor.length > 0">
<div v-if="actorMembers.length > 0">
<p>{{ $t("Add a contact") }}</p>
<p
class="field"
v-for="actor in actorMembersForCurrentActor"
:key="actor.id"
>
<p class="field" v-for="actor in actorMembers" :key="actor.id">
<b-checkbox v-model="actualContacts" :native-value="actor.id">
<div class="media">
<div class="media-left">
@ -138,79 +106,121 @@
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IMember } from "@/types/actor/member.model";
import { IActor, IGroup, IPerson } from "../../types/actor";
import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue";
import { PERSON_MEMBERSHIPS_WITH_MEMBERS } from "../../graphql/actor";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_MEMBERSHIPS,
} from "../../graphql/actor";
import { Paginate } from "../../types/paginate";
import { GROUP_MEMBERS } from "@/graphql/member";
import { ActorType, MemberRole } from "@/types/enums";
const MEMBER_ROLES = [
MemberRole.CREATOR,
MemberRole.ADMINISTRATOR,
MemberRole.MODERATOR,
MemberRole.MEMBER,
];
@Component({
components: { OrganizerPicker },
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS_WITH_MEMBERS,
members: {
query: GROUP_MEMBERS,
variables() {
return {
id: this.identity.id,
name: usernameWithDomain(this.selectedActor),
page: this.membersPage,
limit: 10,
roles: MEMBER_ROLES.join(","),
};
},
update: (data) => data.person.memberships,
update: (data) => data.group.members,
skip() {
return !this.identity.id;
return (
!this.selectedActor || this.selectedActor.type !== ActorType.GROUP
);
},
},
currentActor: CURRENT_ACTOR_CLIENT,
userMemberships: {
query: LOGGED_USER_MEMBERSHIPS,
variables: {
page: 1,
limit: 100,
},
update: (data) => data.loggedUser.memberships,
},
},
})
export default class OrganizerPickerWrapper extends Vue {
@Prop({ type: Object, required: true }) value!: IActor;
@Prop({ type: Object, required: false }) value!: IActor;
@Prop({ default: true, type: Boolean }) inline!: boolean;
@Prop({ type: Object, required: true }) identity!: IPerson;
currentActor!: IPerson;
isComponentModalActive = false;
currentActor: IActor = this.value;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
@Prop({ type: Array, required: false, default: () => [] })
contacts!: IActor[];
members: Paginate<IMember> = { elements: [], total: 0 };
actualContacts: (string | undefined)[] = this.contacts.map(({ id }) => id);
membersPage = 1;
@Watch("contacts")
updateActualContacts(contacts: IActor[]): void {
this.actualContacts = contacts.map(({ id }) => id);
userMemberships: Paginate<IMember> = { elements: [], total: 0 };
get actualContacts(): (string | undefined)[] {
return this.contacts.map(({ id }) => id);
}
@Watch("value")
updateCurrentActor(value: IGroup): void {
this.currentActor = value;
set actualContacts(contactsIds: (string | undefined)[]) {
this.$emit(
"update:contacts",
this.actorMembers.filter(({ id }) => contactsIds.includes(id))
);
}
@Watch("userMemberships")
setInitialActor(): void {
if (this.$route.query?.actorId) {
const actorId = this.$route.query?.actorId as string;
this.$router.replace({ query: undefined });
const actor = this.userMemberships.elements.find(
({ parent: { id }, role }) =>
actorId === id && MEMBER_ROLES.includes(role)
)?.parent as IActor;
this.selectedActor = actor;
}
}
get selectedActor(): IActor | undefined {
if (this.value?.id) {
return this.value;
}
if (this.currentActor) {
return this.currentActor;
}
return undefined;
}
set selectedActor(selectedActor: IActor | undefined) {
this.$emit("input", selectedActor);
}
async relay(group: IGroup): Promise<void> {
this.currentActor = group;
this.actualContacts = [];
this.selectedActor = group;
}
pickActor(): void {
this.$emit(
"update:contacts",
this.actorMembersForCurrentActor.filter(({ id }) =>
this.actualContacts.includes(id)
)
);
this.$emit("input", this.currentActor);
this.isComponentModalActive = false;
}
get actorMembersForCurrentActor(): IActor[] {
const currentMembership = this.groupMemberships.elements.find(
({ parent: { id } }) => id === this.currentActor.id
);
if (currentMembership) {
return currentMembership.parent.members.elements.map(
({ actor }: { actor: IActor }) => actor
);
get actorMembers(): IActor[] {
if (this.selectedActor?.type === ActorType.GROUP) {
return this.members.elements.map(({ actor }: { actor: IActor }) => actor);
}
return [];
}

View File

@ -319,6 +319,7 @@ export const LOGGED_USER_MEMBERSHIPS = gql`
preferredUsername
domain
name
type
avatar {
id
url
@ -359,6 +360,7 @@ export const IDENTITIES = gql`
id
url
}
type
preferredUsername
name
}
@ -379,6 +381,7 @@ export const PERSON_MEMBERSHIPS = gql`
preferredUsername
name
domain
type
avatar {
id
url
@ -397,55 +400,6 @@ export const PERSON_MEMBERSHIPS = gql`
}
`;
export const PERSON_MEMBERSHIPS_WITH_MEMBERS = gql`
query PersonMembershipsWithMembers($id: ID!) {
person(id: $id) {
id
memberships {
total
elements {
id
role
parent {
id
preferredUsername
name
domain
avatar {
id
url
}
members {
total
elements {
id
role
actor {
id
preferredUsername
name
domain
avatar {
id
url
}
}
}
}
}
invitedBy {
id
preferredUsername
name
}
insertedAt
updatedAt
}
}
}
}
`;
export const PERSON_MEMBERSHIP_GROUP = gql`
query PersonMembershipGroup($id: ID!, $group: String!) {
person(id: $id) {

View File

@ -977,5 +977,7 @@
"Create new links": "Create new links",
"You'll need to change the URLs where there were previously entered.": "You'll need to change the URLs where there were previously entered.",
"Personal feeds": "Personal feeds",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page."
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.",
"The event will show as attributed to this profile.": "The event will show as attributed to this profile.",
"You may show some members as contacts.": "You may show some members as contacts."
}

View File

@ -1071,5 +1071,7 @@
"Create new links": "Créer de nouveaux liens",
"You'll need to change the URLs where there were previously entered.": "Vous devrez changer les URLs là où vous les avez entrées précédemment.",
"Personal feeds": "Flux personnels",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils."
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
"The event will show as attributed to this profile.": "L'événement sera affiché comme attribué à ce profil.",
"You may show some members as contacts.": "Vous pouvez afficher certain⋅es membres en tant que contacts."
}

View File

@ -81,19 +81,21 @@
<subtitle>{{ $t("Organizers") }}</subtitle>
<div v-if="config && config.features.groups">
<div v-if="config && config.features.groups && organizerActor.id">
<b-field>
<organizer-picker-wrapper
v-model="event.attributedTo"
v-model="organizerActor"
:contacts.sync="event.contacts"
:identity="event.organizerActor"
/>
</b-field>
<p v-if="!event.attributedTo.id || attributedToEqualToOrganizerActor">
<p v-if="!attributedToAGroup && organizerActorEqualToCurrentActor">
{{
$t("The event will show as attributed to your personal profile.")
}}
</p>
<p v-else-if="!attributedToAGroup">
{{ $t("The event will show as attributed to this profile.") }}
</p>
<p v-else>
<span>{{
$t("The event will show as attributed to this group.")
@ -101,6 +103,7 @@
<span
v-if="event.contacts && event.contacts.length"
v-html="
' ' +
$tc(
'<b>{contact}</b> will be displayed as contact.',
event.contacts.length,
@ -114,6 +117,9 @@
)
"
/>
<span v-else>
{{ $t("You may show some members as contacts.") }}
</span>
</p>
</div>
<subtitle>{{ $t("Who can view this event and participate") }}</subtitle>
@ -432,6 +438,7 @@ import Subtitle from "@/components/Utils/Subtitle.vue";
import { Route } from "vue-router";
import { formatList } from "@/utils/i18n";
import {
ActorType,
CommentModeration,
EventJoinOptions,
EventStatus,
@ -448,10 +455,11 @@ import {
import { EventModel, IEvent } from "../../types/event.model";
import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_DRAFTS,
LOGGED_USER_PARTICIPATIONS,
} from "../../graphql/actor";
import { IPerson, Person, displayNameAndUsername } from "../../types/actor";
import { displayNameAndUsername, IActor, IGroup } from "../../types/actor";
import { TAGS } from "../../graphql/tags";
import { ITag } from "../../types/tag.model";
import {
@ -480,6 +488,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
currentActor: CURRENT_ACTOR_CLIENT,
tags: TAGS,
config: CONFIG,
identities: IDENTITIES,
event: {
query: FETCH_EVENT,
variables() {
@ -513,12 +522,14 @@ export default class EditEvent extends Vue {
@Prop({ type: Boolean, default: false }) isDuplicate!: boolean;
currentActor = new Person();
currentActor!: IActor;
tags: ITag[] = [];
event: IEvent = new EventModel();
identities: IActor[] = [];
config!: IConfig;
unmodifiedEvent!: IEvent;
@ -573,16 +584,32 @@ export default class EditEvent extends Vue {
this.event.beginsOn = now;
this.event.endsOn = end;
this.event.organizerActor = this.getDefaultActor();
}
private getDefaultActor() {
if (this.event.organizerActor?.id) {
get organizerActor(): IActor {
if (this.event?.attributedTo?.id) {
return this.event.attributedTo;
}
if (this.event?.organizerActor?.id) {
return this.event.organizerActor;
}
return this.currentActor;
}
set organizerActor(actor: IActor) {
if (actor?.type === ActorType.GROUP) {
this.event.attributedTo = actor as IGroup;
this.event.organizerActor = this.currentActor;
} else {
this.event.attributedTo = undefined;
this.event.organizerActor = actor;
}
}
get attributedToAGroup(): boolean {
return this.event.attributedTo?.id !== undefined;
}
async mounted(): Promise<void> {
this.observer = new IntersectionObserver(
(entries) => {
@ -724,8 +751,10 @@ export default class EditEvent extends Vue {
return !(
this.eventId &&
this.event.organizerActor?.id !== undefined &&
this.currentActor.id !== this.event.organizerActor.id
) as boolean;
!this.identities
.map(({ id }) => id)
.includes(this.event.organizerActor?.id)
);
}
get updateEventMessage(): string {
@ -752,8 +781,7 @@ export default class EditEvent extends Vue {
*/
private postCreateOrUpdate(store: any, updateEvent: IEvent) {
const resultEvent: IEvent = { ...updateEvent };
const organizerActor: IPerson = this.event.organizerActor as Person;
resultEvent.organizerActor = organizerActor;
resultEvent.organizerActor = this.event.organizerActor;
resultEvent.relatedEvents = [];
store.writeQuery({
@ -766,12 +794,12 @@ export default class EditEvent extends Vue {
query: EVENT_PERSON_PARTICIPATION,
variables: {
eventId: updateEvent.id,
name: organizerActor.preferredUsername,
name: this.event.organizerActor?.preferredUsername,
},
data: {
person: {
__typename: "Person",
id: organizerActor.id,
id: this.event?.organizerActor?.id,
participations: {
__typename: "PaginatedParticipantList",
total: 1,
@ -782,7 +810,7 @@ export default class EditEvent extends Vue {
role: ParticipantRole.CREATOR,
actor: {
__typename: "Actor",
id: organizerActor.id,
id: this.event?.organizerActor?.id,
},
event: {
__typename: "Event",
@ -819,30 +847,26 @@ export default class EditEvent extends Vue {
];
}
get attributedToEqualToOrganizerActor(): boolean {
return (this.event.organizerActor?.id !== undefined &&
this.event.attributedTo?.id === this.event.organizerActor?.id) as boolean;
get organizerActorEqualToCurrentActor(): boolean {
return (
this.currentActor?.id !== undefined &&
this.organizerActor?.id === this.currentActor?.id
);
}
/**
* Build variables for Event GraphQL creation query
*/
private async buildVariables() {
this.event.organizerActor = this.event.organizerActor?.id
? this.event.organizerActor
: this.currentActor;
let res = this.event.toEditJSON();
if (this.event.organizerActor) {
res = Object.assign(res, {
organizerActorId: this.event.organizerActor.id,
});
}
const attributedToId =
this.event.attributedTo &&
!this.attributedToEqualToOrganizerActor &&
this.event.attributedTo.id
? this.event.attributedTo.id
: null;
const attributedToId = this.event.attributedTo?.id
? this.event.attributedTo.id
: null;
res = Object.assign(res, { attributedToId });
// eslint-disable-next-line

View File

@ -327,6 +327,7 @@
v-if="isCurrentActorAGroupModerator"
:to="{
name: RouteName.CREATE_EVENT,
query: { actorId: group.id },
}"
class="button is-primary"
>{{ $t("+ Create an event") }}</router-link

View File

@ -315,7 +315,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
context: %{current_user: user}
}) do
with {:is_owned, %Actor{id: actor_id}} <- User.owns_actor(user, actor_id),
%Actor{id: group_id} <- Actors.get_actor_by_name(group, :Group),
{:group, %Actor{id: group_id}} <- {:group, Actors.get_actor_by_name(group, :Group)},
{:ok, %Member{} = membership} <- Actors.get_member(actor_id, group_id),
memberships <- %Page{
total: 1,
@ -326,6 +326,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
{:error, :member_not_found} ->
{:ok, %Page{total: 0, elements: []}}
{:group, nil} ->
{:error, :group_not_found}
{:is_owned, nil} ->
{:error, dgettext("errors", "Profile is not owned by authenticated user")}
end