Merge branch 'feature/group-create' into 'master'

Prepare create group

See merge request framasoft/mobilizon!173
This commit is contained in:
Thomas Citharel 2019-09-02 12:10:23 +02:00
commit 925f7bbb02
11 changed files with 257 additions and 120 deletions

View file

@ -40,6 +40,10 @@
<router-link :to="{ name: 'UpdateIdentity' }" v-translate>My account</router-link> <router-link :to="{ name: 'UpdateIdentity' }" v-translate>My account</router-link>
</span> </span>
<span class="navbar-item">
<router-link :to="{ name: ActorRouteName.CREATE_GROUP }" v-translate>Create group</router-link>
</span>
<a v-translate class="navbar-item" v-on:click="logout()">Log out</a> <a v-translate class="navbar-item" v-on:click="logout()">Log out</a>
</div> </div>
</div> </div>
@ -70,6 +74,7 @@ import { IConfig } from '@/types/config.model';
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser } from '@/types/current-user.model';
import Logo from '@/components/Logo.vue'; import Logo from '@/components/Logo.vue';
import SearchField from '@/components/SearchField.vue'; import SearchField from '@/components/SearchField.vue';
import { ActorRouteName } from '@/router/actor';
@Component({ @Component({
apollo: { apollo: {
@ -95,6 +100,8 @@ export default class NavBar extends Vue {
currentUser!: ICurrentUser; currentUser!: ICurrentUser;
showNavbar: boolean = false; showNavbar: boolean = false;
ActorRouteName = ActorRouteName;
@Watch('currentUser') @Watch('currentUser')
async onCurrentUserChanged() { async onCurrentUserChanged() {
// Refresh logged person object // Refresh logged person object

View file

@ -174,3 +174,34 @@ query($name:String!) {
} }
} }
`; `;
export const CREATE_GROUP = gql`
mutation CreateGroup(
$creatorActorId: Int!,
$preferredUsername: String!,
$name: String!,
$summary: String,
$avatar: PictureInput,
$banner: PictureInput
) {
createGroup(
creatorActorId: $creatorActorId,
preferredUsername: $preferredUsername,
name: $name,
summary: $summary,
banner: $banner,
avatar: $avatar
) {
id,
preferredUsername,
name,
summary,
avatar {
url
},
banner {
url
}
}
}
`;

View file

@ -19,4 +19,14 @@ export interface IMember {
export class Group extends Actor implements IGroup { export class Group extends Actor implements IGroup {
members: IMember[] = []; members: IMember[] = [];
constructor(hash: IGroup | {} = {}) {
super(hash);
this.patch(hash);
}
patch (hash: any) {
Object.assign(this, hash);
}
} }

View file

@ -1,87 +1,124 @@
<template> <template>
<section> <div class="root">
<h1> <h1 v-translate>Create a new group</h1>
<translate>Create a new group</translate>
</h1>
<div class="columns">
<form class="column" @submit="createGroup">
<b-field :label="$gettext('Group name')">
<b-input aria-required="true" required v-model="group.preferred_username"/>
</b-field>
<b-field :label="$gettext('Group full name')"> <div>
<b-input aria-required="true" required v-model="group.name"/> <b-field :label="$gettext('Group name')">
</b-field> <b-input aria-required="true" required v-model="group.preferred_username"/>
</b-field>
<b-field :label="$gettext('Description')"> <b-field :label="$gettext('Group full name')">
<b-input aria-required="true" required v-model="group.summary" type="textarea"/> <b-input aria-required="true" required v-model="group.name"/>
</b-field> </b-field>
<button class="button is-primary"> <b-field :label="$gettext('Description')">
<translate>Create my group</translate> <b-input aria-required="true" required v-model="group.description" type="textarea"/>
</button> </b-field>
</form>
<div>
Avatar
<picture-upload v-model="avatarFile"></picture-upload>
</div>
<div>
Banner
<picture-upload v-model="avatarFile"></picture-upload>
</div>
<button class="button is-primary" @click="createGroup()">
<translate>Create my group</translate>
</button>
</div> </div>
</section> </div>
</template> </template>
<style lang="scss" scoped>
.root {
width: 400px;
margin: auto;
}
</style>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { Group, IPerson } from '@/types/actor';
import { CREATE_GROUP, LOGGED_PERSON } from '@/graphql/actor';
import { RouteName } from '@/router';
import PictureUpload from '@/components/PictureUpload.vue';
@Component({}) @Component({
components: {
PictureUpload,
},
apollo: {
loggedPerson: {
query: LOGGED_PERSON,
},
},
})
export default class CreateGroup extends Vue { export default class CreateGroup extends Vue {
e1 = 0; loggedPerson!: IPerson;
// FIXME: correctly type group
group: {
preferred_username: string;
name: string;
summary: string;
address?: any;
} = {
preferred_username: '',
name: '',
summary: '',
// category: null,
};
categories = [];
mounted() { group = new Group();
this.fetchCategories();
avatarFile: File | null = null;
bannerFile: File | null = null;
async createGroup() {
try {
await this.$apollo.mutate({
mutation: CREATE_GROUP,
variables: this.buildVariables(),
update: (store, { data: { createGroup } }) => {
// TODO: update group list cache
},
});
this.$router.push({ name: RouteName.GROUP, params: { identityName: this.group.preferredUsername } });
this.$notifier.success(
this.$gettextInterpolate('Group %{displayName} created', { displayName: this.group.displayName() }),
);
} catch (err) {
this.handleError(err);
}
} }
createGroup() { private buildVariables() {
// this.group.organizer = "/accounts/" + this.$store.state.user.id; let avatarObj = {};
// FIXME: remove eventFetch let bannerObj = {};
// eventFetch('/groups', this.$store, { method: 'POST', body: JSON.stringify({ group: this.group }) })
// .then(response => response.json())
// .then((data) => {
// this.loading = false;
// this.$router.push({ path: 'Group', params: { id: data.id } });
// });
}
fetchCategories() { if (this.avatarFile) {
// FIXME: remove eventFetch avatarObj = {
// eventFetch('/categories', this.$store) avatar: {
// .then(response => response.json()) picture: {
// .then((data) => { name: this.avatarFile.name,
// this.loading = false; alt: `${this.group.preferredUsername}'s avatar`,
// this.categories = data.data; file: this.avatarFile,
// }); },
} },
};
}
getAddressData(addressData) { if (this.bannerFile) {
this.group.address = { bannerObj = {
geo: { picture: {
latitude: addressData.latitude, name: this.bannerFile.name,
longitude: addressData.longitude, alt: `${this.group.preferredUsername}'s banner`,
}, file: this.bannerFile,
country: addressData.country, },
locality: addressData.city, };
region: addressData.administrative_area_level_1, }
postalCode: addressData.postalCode,
street: `${addressData.street_number} ${addressData.route}`, const currentActor = {
creatorActorId: this.loggedPerson.id,
}; };
return Object.assign({}, this.group, avatarObj, bannerObj, currentActor);
}
private handleError(err: any) {
console.error(err);
} }
} }
</script> </script>

View file

@ -107,7 +107,7 @@ const apolloClient = new ApolloClient({
cache, cache,
link, link,
connectToDevTools: true, connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache) resolvers: buildCurrentUserResolver(cache),
}); });
export const apolloProvider = new VueApollo({ export const apolloProvider = new VueApollo({

View file

@ -3,7 +3,7 @@ defmodule MobilizonWeb.API.Groups do
API for Events API for Events
""" """
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Users.User
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils
alias MobilizonWeb.API.Utils alias MobilizonWeb.API.Utils
@ -11,24 +11,26 @@ defmodule MobilizonWeb.API.Groups do
@doc """ @doc """
Create a group Create a group
""" """
@spec create_group(map()) :: {:ok, Activity.t(), Group.t()} | any() @spec create_group(User.t(), map()) :: {:ok, Activity.t(), Group.t()} | any()
def create_group( def create_group(
user,
%{ %{
preferred_username: title, preferred_username: title,
description: description, summary: summary,
admin_actor_username: admin_actor_username creator_actor_id: creator_actor_id,
avatar: avatar,
banner: banner
} = args } = args
) do ) do
with {:bad_actor, %Actor{url: url} = actor} <- with {:is_owned, true, actor} <- User.owns_actor(user, creator_actor_id),
{:bad_actor, Actors.get_local_actor_by_name(admin_actor_username)},
{:existing_group, nil} <- {:existing_group, Actors.get_group_by_title(title)}, {:existing_group, nil} <- {:existing_group, Actors.get_group_by_title(title)},
title <- String.trim(title), title <- String.trim(title),
visibility <- Map.get(args, :visibility, :public), visibility <- Map.get(args, :visibility, :public),
{content_html, tags, to, cc} <- {content_html, tags, to, cc} <-
Utils.prepare_content(actor, description, visibility, [], nil), Utils.prepare_content(actor, summary, visibility, [], nil),
group <- group <-
ActivityPubUtils.make_group_data( ActivityPubUtils.make_group_data(
url, actor.url,
to, to,
title, title,
content_html, content_html,
@ -43,10 +45,10 @@ defmodule MobilizonWeb.API.Groups do
}) })
else else
{:existing_group, _} -> {:existing_group, _} ->
{:error, :existing_group_name} {:error, "A group with this name already exists"}
{:bad_actor, _} -> {:is_owned, _} ->
{:error, :bad_admin_actor} {:error, "Actor id is not owned by authenticated user"}
end end
end end
end end

View file

@ -27,8 +27,11 @@ defmodule MobilizonWeb.Resolvers.Group do
Lists all groups Lists all groups
""" """
def list_groups(_parent, %{page: page, limit: limit}, _resolution) do def list_groups(_parent, %{page: page, limit: limit}, _resolution) do
{:ok, {
Actors.list_groups(page, limit) |> Enum.map(fn actor -> Person.proxify_pictures(actor) end)} :ok,
Actors.list_groups(page, limit)
|> Enum.map(fn actor -> Person.proxify_pictures(actor) end)
}
end end
@doc """ @doc """
@ -39,7 +42,7 @@ defmodule MobilizonWeb.Resolvers.Group do
args, args,
%{ %{
context: %{ context: %{
current_user: _user current_user: user
} }
} }
) do ) do
@ -52,26 +55,22 @@ defmodule MobilizonWeb.Resolvers.Group do
}, },
%Actor{} = group %Actor{} = group
} <- } <-
MobilizonWeb.API.Groups.create_group(args) do MobilizonWeb.API.Groups.create_group(
user,
%{
preferred_username: args.preferred_username,
creator_actor_id: args.creator_actor_id,
name: Map.get(args, "name", args.preferred_username),
summary: args.summary,
avatar: Map.get(args, "avatar"),
banner: Map.get(args, "banner")
}
) do
{ {
:ok, :ok,
group group
} }
end end
# with %Actor{id: actor_id} <- Actors.get_local_actor_by_name(actor_username),
# {:user_actor, true} <-
# {:user_actor, actor_id in Enum.map(Actors.get_actors_for_user(user), & &1.id)},
# {:ok, %Actor{} = group} <- Actors.create_group(%{preferred_username: preferred_username}) do
# {:ok, group}
# else
# {:error, %Ecto.Changeset{errors: [url: {"has already been taken", []}]}} ->
# {:error, :group_name_not_available}
# err ->
# Logger.error(inspect(err))
# err
# end
end end
def create_group(_parent, _args, _resolution) do def create_group(_parent, _args, _resolution) do
@ -138,12 +137,18 @@ defmodule MobilizonWeb.Resolvers.Group do
actor_id: actor.id, actor_id: actor.id,
role: role role: role
}) do }) do
{:ok, {
%{ :ok,
parent: group |> Person.proxify_pictures(), %{
actor: actor |> Person.proxify_pictures(), parent:
role: role group
}} |> Person.proxify_pictures(),
actor:
actor
|> Person.proxify_pictures(),
role: role
}
}
else else
{:is_owned, false} -> {:is_owned, false} ->
{:error, "Actor id is not owned by authenticated user"} {:error, "Actor id is not owned by authenticated user"}

View file

@ -95,13 +95,14 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
@desc "Create a group" @desc "Create a group"
field :create_group, :group do field :create_group, :group do
arg(:preferred_username, non_null(:string), description: "The name for the group") arg(:preferred_username, non_null(:string), description: "The name for the group")
arg(:name, :string, description: "The displayed name for the group")
arg(:description, :string, description: "The summary for the group", default_value: "")
arg(:admin_actor_username, :string, arg(:creator_actor_id, non_null(:integer),
description: "The actor's username which will be the admin (otherwise user's default one)" description: "The identity that creates the group"
) )
arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "")
arg(:avatar, :picture_input, arg(:avatar, :picture_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing Picture"

View file

@ -72,7 +72,7 @@ defmodule MobilizonWeb.Schema.EventType do
@desc "The list of visibility options for an event" @desc "The list of visibility options for an event"
enum :event_visibility do enum :event_visibility do
value(:public, description: "Publically listed and federated. Can be shared.") value(:public, description: "Publicly listed and federated. Can be shared.")
value(:unlisted, description: "Visible only to people with the link - or invited") value(:unlisted, description: "Visible only to people with the link - or invited")
value(:private, value(:private,

View file

@ -15,12 +15,37 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
end end
describe "Group Resolver" do describe "Group Resolver" do
test "create_group/3 creates a group", %{conn: conn, user: user, actor: actor} do test "create_group/3 should check the user owns the identity", %{conn: conn, user: user} do
another_actor = insert(:actor)
mutation = """ mutation = """
mutation { mutation {
createGroup( createGroup(
preferred_username: "#{@new_group_params.groupname}", preferred_username: "#{@new_group_params.groupname}",
admin_actor_username: "#{actor.preferred_username}" creator_actor_id: #{another_actor.id}
) {
preferred_username,
type
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Actor id is not owned by authenticated user"
end
test "create_group/3 creates a group and check a group with this name does not already exist",
%{conn: conn, user: user, actor: actor} do
mutation = """
mutation {
createGroup(
preferred_username: "#{@new_group_params.groupname}",
creator_actor_id: #{actor.id}
) { ) {
preferred_username, preferred_username,
type type
@ -42,7 +67,7 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
mutation { mutation {
createGroup( createGroup(
preferred_username: "#{@new_group_params.groupname}", preferred_username: "#{@new_group_params.groupname}",
admin_actor_username: "#{actor.preferred_username}", creator_actor_id: #{actor.id},
) { ) {
preferred_username, preferred_username,
type type
@ -55,7 +80,8 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
|> auth_conn(user) |> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] == "existing_group_name" assert hd(json_response(res, 200)["errors"])["message"] ==
"A group with this name already exists"
end end
test "list_groups/3 returns all public or unlisted groups", context do test "list_groups/3 returns all public or unlisted groups", context do

View file

@ -7,7 +7,9 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
@event %{ @event %{
description: "some body", description: "some body",
title: "some title", title: "some title",
begins_on: DateTime.utc_now() |> DateTime.truncate(:second), begins_on:
DateTime.utc_now()
|> DateTime.truncate(:second),
uuid: "b5126423-f1af-43e4-a923-002a03003ba4", uuid: "b5126423-f1af-43e4-a923-002a03003ba4",
url: "some url", url: "some url",
category: "meeting" category: "meeting"
@ -171,7 +173,9 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["participants"] == [
%{ %{
"actor" => %{"preferredUsername" => participant2.actor.preferred_username}, "actor" => %{
"preferredUsername" => participant2.actor.preferred_username
},
"role" => "creator" "role" => "creator"
} }
] ]
@ -339,7 +343,9 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["participants"] == [
%{ %{
"actor" => %{"preferredUsername" => context.actor.preferred_username}, "actor" => %{
"preferredUsername" => context.actor.preferred_username
},
"role" => "creator" "role" => "creator"
} }
] ]
@ -356,14 +362,26 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
context.conn context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["data"]["participants"] == [ sorted_participants =
json_response(res, 200)["data"]["participants"]
|> Enum.sort_by(
&(&1
|> Map.get("actor")
|> Map.get("preferredUsername"))
)
assert sorted_participants == [
%{ %{
"actor" => %{"preferredUsername" => participant2.actor.preferred_username}, "actor" => %{
"role" => "participant" "preferredUsername" => context.actor.preferred_username
},
"role" => "creator"
}, },
%{ %{
"actor" => %{"preferredUsername" => context.actor.preferred_username}, "actor" => %{
"role" => "creator" "preferredUsername" => participant2.actor.preferred_username
},
"role" => "participant"
} }
] ]
end end