Fix posts and rework graphql errors
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
92367a5f33
commit
aced4d039b
@ -83,16 +83,16 @@ export default class DateTimePicker extends Vue {
|
||||
localeMonthNamesProxy = localeMonthNames();
|
||||
|
||||
@Watch("value")
|
||||
updateValue() {
|
||||
updateValue(): void {
|
||||
this.dateWithTime = this.value;
|
||||
}
|
||||
|
||||
@Watch("dateWithTime")
|
||||
updateDateWithTimeWatcher() {
|
||||
updateDateWithTimeWatcher(): void {
|
||||
this.updateDateTime();
|
||||
}
|
||||
|
||||
updateDateTime() {
|
||||
updateDateTime(): void {
|
||||
/**
|
||||
* Returns the updated date
|
||||
*
|
||||
@ -115,6 +115,7 @@ export default class DateTimePicker extends Vue {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private datesAreOnSameDay(first: Date, second: Date): boolean {
|
||||
return (
|
||||
first.getFullYear() === second.getFullYear() &&
|
||||
|
107
js/src/components/Post/PostElementItem.vue
Normal file
107
js/src/components/Post/PostElementItem.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="post-minimalist-card-wrapper"
|
||||
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
|
||||
>
|
||||
<div class="title-info-wrapper">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-96x96" v-if="post.picture">
|
||||
<img :src="post.picture.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="post" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="post-minimalist-title">{{ post.title }}</p>
|
||||
<div class="metadata">
|
||||
<b-tag type="is-warning" size="is-small" v-if="post.draft">{{ $t("Draft") }}</b-tag>
|
||||
<small v-if="post.visibility === PostVisibility.PUBLIC" class="has-text-grey">
|
||||
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
|
||||
>
|
||||
<small v-else-if="post.visibility === PostVisibility.UNLISTED" class="has-text-grey">
|
||||
<b-icon icon="link" size="is-small" />{{ $t("Accessible through link") }}</small
|
||||
>
|
||||
<small v-else-if="post.visibility === PostVisibility.PRIVATE" class="has-text-grey">
|
||||
<b-icon icon="lock" size="is-small" />{{
|
||||
$t("Accessible only to members", { group: post.attributedTo.name })
|
||||
}}</small
|
||||
>
|
||||
<small class="has-text-grey">{{
|
||||
$options.filters.formatDateTimeString(new Date(post.insertedAt), false)
|
||||
}}</small>
|
||||
<small class="has-text-grey" v-if="isCurrentActorMember">{{
|
||||
$t("Created by {username}", { username: usernameWithDomain(post.author) })
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { usernameWithDomain } from "@/types/actor";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import RouteName from "../../router/name";
|
||||
import { IPost, PostVisibility } from "../../types/post.model";
|
||||
|
||||
@Component
|
||||
export default class PostElementItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) post!: IPost;
|
||||
|
||||
@Prop({ required: false, type: Boolean, default: false }) isCurrentActorMember!: boolean;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.post-minimalist-card-wrapper {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
color: initial;
|
||||
border-bottom: 1px solid #e9e9e9;
|
||||
align-items: center;
|
||||
|
||||
.title-info-wrapper {
|
||||
flex: 2;
|
||||
|
||||
.post-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.media .media-left {
|
||||
& > span.icon {
|
||||
height: 96px;
|
||||
width: 96px;
|
||||
}
|
||||
& > figure.image > img {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
object-position: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.metadata {
|
||||
& > span.tag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
& > small:not(:last-child):after {
|
||||
content: "·";
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,3 +1,5 @@
|
||||
import { DateTimeFormatOptions } from "vue-i18n";
|
||||
|
||||
function parseDateTime(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
@ -16,19 +18,21 @@ function formatTimeString(value: string): string {
|
||||
}
|
||||
|
||||
function formatDateTimeString(value: string, showTime = true): string {
|
||||
const options = {
|
||||
weekday: "long",
|
||||
const options: DateTimeFormatOptions = {
|
||||
weekday: undefined,
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour: undefined,
|
||||
minute: undefined,
|
||||
};
|
||||
if (showTime) {
|
||||
options.weekday = "long";
|
||||
options.hour = "numeric";
|
||||
options.minute = "numeric";
|
||||
}
|
||||
return parseDateTime(value).toLocaleTimeString(undefined, options);
|
||||
const format = new Intl.DateTimeFormat(undefined, options);
|
||||
return format.format(parseDateTime(value));
|
||||
}
|
||||
|
||||
export { formatDateString, formatTimeString, formatDateTimeString };
|
||||
|
@ -34,6 +34,11 @@ export const POST_FRAGMENT = gql`
|
||||
tags {
|
||||
...TagFragment
|
||||
}
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
}
|
||||
}
|
||||
${TAG_FRAGMENT}
|
||||
`;
|
||||
@ -48,6 +53,7 @@ export const POST_BASIC_FIELDS = gql`
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
domain
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
@ -56,6 +62,7 @@ export const POST_BASIC_FIELDS = gql`
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
domain
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
@ -64,6 +71,12 @@ export const POST_BASIC_FIELDS = gql`
|
||||
updatedAt
|
||||
publishAt
|
||||
draft
|
||||
visibility
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -102,6 +115,7 @@ export const CREATE_POST = gql`
|
||||
$visibility: PostVisibility
|
||||
$draft: Boolean
|
||||
$tags: [String]
|
||||
$picture: PictureInput
|
||||
) {
|
||||
createPost(
|
||||
title: $title
|
||||
@ -110,6 +124,7 @@ export const CREATE_POST = gql`
|
||||
visibility: $visibility
|
||||
draft: $draft
|
||||
tags: $tags
|
||||
picture: $picture
|
||||
) {
|
||||
...PostFragment
|
||||
}
|
||||
@ -126,6 +141,7 @@ export const UPDATE_POST = gql`
|
||||
$visibility: PostVisibility
|
||||
$draft: Boolean
|
||||
$tags: [String]
|
||||
$picture: PictureInput
|
||||
) {
|
||||
updatePost(
|
||||
id: $id
|
||||
@ -135,6 +151,7 @@ export const UPDATE_POST = gql`
|
||||
visibility: $visibility
|
||||
draft: $draft
|
||||
tags: $tags
|
||||
picture: $picture
|
||||
) {
|
||||
...PostFragment
|
||||
}
|
||||
|
@ -779,5 +779,8 @@
|
||||
"Error while reporting group {groupTitle}": "Error while reporting group {groupTitle}",
|
||||
"Reported group": "Reported group",
|
||||
"You can only get invited to groups right now.": "You can only get invited to groups right now.",
|
||||
"Join group": "Join group"
|
||||
"Join group": "Join group",
|
||||
"Created by {username}": "Created by {username}",
|
||||
"Accessible through link": "Accessible through link",
|
||||
"Accessible only to members": "Accessible only to members"
|
||||
}
|
||||
|
@ -816,5 +816,8 @@
|
||||
"Error while reporting group {groupTitle}": "Erreur lors du signalement du groupe {groupTitle}",
|
||||
"Reported group": "Groupe signalé",
|
||||
"You can only get invited to groups right now.": "Vous pouvez uniquement être invité aux groupes pour le moment.",
|
||||
"Join group": "Rejoindre le groupe"
|
||||
"Join group": "Rejoindre le groupe",
|
||||
"Created by {username}": "Créé par {username}",
|
||||
"Accessible through link": "Accessible uniquement par lien",
|
||||
"Accessible only to members": "Accessible uniquement aux membres"
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { IPicture } from "@/types/picture.model";
|
||||
|
||||
export async function buildFileFromIPicture(obj: IPicture | null) {
|
||||
export async function buildFileFromIPicture(obj: IPicture | null | undefined) {
|
||||
if (!obj) return null;
|
||||
|
||||
const response = await fetch(obj.url);
|
||||
|
@ -335,6 +335,7 @@ section {
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||
import { RefetchQueryDescription } from "apollo-client/core/watchQueryOptions";
|
||||
import PictureUpload from "@/components/PictureUpload.vue";
|
||||
import EditorComponent from "@/components/Editor.vue";
|
||||
import DateTimePicker from "@/components/Event/DateTimePicker.vue";
|
||||
@ -373,7 +374,6 @@ import RouteName from "../../router/name";
|
||||
import "intersection-observer";
|
||||
import { CONFIG } from "../../graphql/config";
|
||||
import { IConfig } from "../../types/config.model";
|
||||
import { RefetchQueryDescription } from "apollo-client/core/watchQueryOptions";
|
||||
|
||||
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
|
||||
|
||||
|
@ -965,12 +965,12 @@ export default class Event extends EventMixin {
|
||||
// @ts-ignore-end
|
||||
}
|
||||
|
||||
async handleErrors(errors: GraphQLError[]): Promise<void> {
|
||||
handleErrors(errors: any[]): void {
|
||||
if (
|
||||
errors[0].message.includes("not found") ||
|
||||
errors[0].message.includes("has invalid value $uuid")
|
||||
errors.some((error) => error.status_code === 404) ||
|
||||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
|
||||
) {
|
||||
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,9 +8,17 @@
|
||||
{{ $t("Add a new post") }}
|
||||
</h1>
|
||||
<subtitle>{{ $t("General information") }}</subtitle>
|
||||
<picture-upload v-model="pictureFile" :textFallback="$t('Headline picture')" />
|
||||
<picture-upload
|
||||
v-model="pictureFile"
|
||||
:textFallback="$t('Headline picture')"
|
||||
:defaultImageSrc="post.picture ? post.picture.url : null"
|
||||
/>
|
||||
|
||||
<b-field :label="$t('Title')">
|
||||
<b-field
|
||||
:label="$t('Title')"
|
||||
:type="errors.title ? 'is-danger' : null"
|
||||
:message="errors.title"
|
||||
>
|
||||
<b-input size="is-large" aria-required="true" required v-model="post.title" />
|
||||
</b-field>
|
||||
|
||||
@ -18,6 +26,7 @@
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t("Post") }}</label>
|
||||
<p v-if="errors.body" class="help is-danger">{{ errors.body }}</p>
|
||||
<editor v-model="post.body" />
|
||||
</div>
|
||||
<subtitle>{{ $t("Who can view this post") }}</subtitle>
|
||||
@ -80,6 +89,7 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { buildFileFromIPicture, readFileAsync } from "@/utils/image";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { TAGS } from "../../graphql/tags";
|
||||
import { CONFIG } from "../../graphql/config";
|
||||
@ -87,9 +97,11 @@ import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql
|
||||
|
||||
import { IPost, PostVisibility } from "../../types/post.model";
|
||||
import Editor from "../../components/Editor.vue";
|
||||
import { IGroup } from "../../types/actor";
|
||||
import { IActor, IGroup } from "../../types/actor";
|
||||
import TagInput from "../../components/Event/TagInput.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import Subtitle from "../../components/Utils/Subtitle.vue";
|
||||
import PictureUpload from "../../components/PictureUpload.vue";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -123,10 +135,12 @@ import RouteName from "../../router/name";
|
||||
components: {
|
||||
Editor,
|
||||
TagInput,
|
||||
Subtitle,
|
||||
PictureUpload,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
title: this.isUpdate
|
||||
? (this.$t("Edit post") as string)
|
||||
@ -156,7 +170,18 @@ export default class EditPost extends Vue {
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
|
||||
async publish(draft: boolean) {
|
||||
pictureFile: File | null = null;
|
||||
|
||||
errors: Record<string, unknown> = {};
|
||||
|
||||
async mounted(): Promise<void> {
|
||||
this.pictureFile = await buildFileFromIPicture(this.post.picture);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
async publish(draft: boolean): Promise<void> {
|
||||
this.errors = {};
|
||||
|
||||
if (this.isUpdate) {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: UPDATE_POST,
|
||||
@ -167,28 +192,38 @@ export default class EditPost extends Vue {
|
||||
tags: (this.post.tags || []).map(({ title }) => title),
|
||||
visibility: this.post.visibility,
|
||||
draft,
|
||||
...(await this.buildPicture()),
|
||||
},
|
||||
});
|
||||
if (data && data.updatePost) {
|
||||
return this.$router.push({ name: RouteName.POST, params: { slug: data.updatePost.slug } });
|
||||
this.$router.push({ name: RouteName.POST, params: { slug: data.updatePost.slug } });
|
||||
}
|
||||
} else {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: CREATE_POST,
|
||||
variables: {
|
||||
...this.post,
|
||||
tags: (this.post.tags || []).map(({ title }) => title),
|
||||
attributedToId: this.group.id,
|
||||
draft,
|
||||
},
|
||||
});
|
||||
if (data && data.createPost) {
|
||||
return this.$router.push({ name: RouteName.POST, params: { slug: data.createPost.slug } });
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: CREATE_POST,
|
||||
variables: {
|
||||
...this.post,
|
||||
...(await this.buildPicture()),
|
||||
tags: (this.post.tags || []).map(({ title }) => title),
|
||||
attributedToId: this.actualGroup.id,
|
||||
draft,
|
||||
},
|
||||
});
|
||||
if (data && data.createPost) {
|
||||
this.$router.push({ name: RouteName.POST, params: { slug: data.createPost.slug } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.errors = error.graphQLErrors.reduce((acc: { [key: string]: any }, localError: any) => {
|
||||
acc[localError.field] = EditPost.transformMessage(localError.message);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deletePost() {
|
||||
async deletePost(): Promise<void> {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: DELETE_POST,
|
||||
variables: {
|
||||
@ -196,12 +231,58 @@ export default class EditPost extends Vue {
|
||||
},
|
||||
});
|
||||
if (data && this.post.attributedTo) {
|
||||
return this.$router.push({
|
||||
this.$router.push({
|
||||
name: RouteName.POSTS,
|
||||
params: { preferredUsername: this.post.attributedTo.preferredUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static transformMessage(message: string[] | string): string | undefined {
|
||||
if (Array.isArray(message) && message.length > 0) {
|
||||
return message[0];
|
||||
}
|
||||
if (typeof message === "string") {
|
||||
return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async buildPicture(): Promise<Record<string, unknown>> {
|
||||
let obj: { picture?: any } = {};
|
||||
if (this.pictureFile) {
|
||||
const pictureObj = {
|
||||
picture: {
|
||||
picture: {
|
||||
name: this.pictureFile.name,
|
||||
alt: `${this.actualGroup.preferredUsername}'s avatar`,
|
||||
file: this.pictureFile,
|
||||
},
|
||||
},
|
||||
};
|
||||
obj = { ...pictureObj };
|
||||
}
|
||||
try {
|
||||
if (this.post.picture) {
|
||||
const oldPictureFile = (await buildFileFromIPicture(this.post.picture)) as File;
|
||||
const oldPictureFileContent = await readFileAsync(oldPictureFile);
|
||||
const newPictureFileContent = await readFileAsync(this.pictureFile as File);
|
||||
if (oldPictureFileContent === newPictureFileContent) {
|
||||
obj.picture = { pictureId: this.post.picture.id };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
get actualGroup(): IActor {
|
||||
if (!this.group) {
|
||||
return this.post.attributedTo as IActor;
|
||||
}
|
||||
return this.group;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
@ -2,9 +2,6 @@
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
v-if="group"
|
||||
@ -30,20 +27,31 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"A place to publish something to the whole world, your community or just your group members."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
v-for="post in group.posts.elements"
|
||||
:key="post.id"
|
||||
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
|
||||
>
|
||||
{{ post.title }}
|
||||
</router-link>
|
||||
<div class="intro">
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"A place to publish something to the whole world, your community or just your group members."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.POST_CREATE,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
class="button is-primary"
|
||||
>{{ $t("+ Post a public message") }}</router-link
|
||||
>
|
||||
</div>
|
||||
<div class="post-list">
|
||||
<post-element-item
|
||||
v-for="post in group.posts.elements"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:isCurrentActorMember="isCurrentActorMember"
|
||||
/>
|
||||
</div>
|
||||
<b-loading :active.sync="$apollo.loading"></b-loading>
|
||||
<b-message
|
||||
v-if="group.posts.elements.length === 0 && $apollo.loading === false"
|
||||
@ -51,37 +59,66 @@
|
||||
>
|
||||
{{ $t("No posts found") }}
|
||||
</b-message>
|
||||
<b-pagination
|
||||
:total="group.posts.total"
|
||||
v-model="postsPage"
|
||||
:per-page="POSTS_PAGE_LIMIT"
|
||||
:aria-next-label="$t('Next page')"
|
||||
:aria-previous-label="$t('Previous page')"
|
||||
:aria-page-label="$t('Page')"
|
||||
:aria-current-label="$t('Current page')"
|
||||
>
|
||||
</b-pagination>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { FETCH_GROUP_POSTS } from "../../graphql/post";
|
||||
import { Paginate } from "../../types/paginate";
|
||||
import { IPost } from "../../types/post.model";
|
||||
import { IGroup, usernameWithDomain } from "../../types/actor";
|
||||
import { IGroup, IMember, IPerson, usernameWithDomain } from "../../types/actor";
|
||||
import RouteName from "../../router/name";
|
||||
import PostElementItem from "../../components/Post/PostElementItem.vue";
|
||||
|
||||
const POSTS_PAGE_LIMIT = 10;
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
memberships: {
|
||||
query: PERSON_MEMBERSHIPS,
|
||||
fetchPolicy: "cache-and-network",
|
||||
variables() {
|
||||
return {
|
||||
id: this.currentActor.id,
|
||||
};
|
||||
},
|
||||
update: (data) => data.person.memberships.elements,
|
||||
skip() {
|
||||
return !this.currentActor || !this.currentActor.id;
|
||||
},
|
||||
},
|
||||
group: {
|
||||
query: FETCH_GROUP_POSTS,
|
||||
fetchPolicy: "cache-and-network",
|
||||
variables() {
|
||||
return {
|
||||
preferredUsername: this.preferredUsername,
|
||||
page: this.postsPage,
|
||||
limit: POSTS_PAGE_LIMIT,
|
||||
};
|
||||
},
|
||||
// update(data) {
|
||||
// console.log(data);
|
||||
// return data.group.posts;
|
||||
// },
|
||||
skip() {
|
||||
return !this.preferredUsername;
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
PostElementItem,
|
||||
},
|
||||
})
|
||||
export default class PostList extends Vue {
|
||||
@Prop({ required: true, type: String }) preferredUsername!: string;
|
||||
@ -90,8 +127,29 @@ export default class PostList extends Vue {
|
||||
|
||||
posts!: Paginate<IPost>;
|
||||
|
||||
memberships!: IMember[];
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
postsPage = 1;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
POSTS_PAGE_LIMIT = POSTS_PAGE_LIMIT;
|
||||
|
||||
get isCurrentActorMember(): boolean {
|
||||
if (!this.group || !this.memberships) return false;
|
||||
return this.memberships.map(({ parent: { id } }) => id).includes(this.group.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
section {
|
||||
div.intro,
|
||||
.post-list {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -14,6 +14,10 @@
|
||||
>
|
||||
</i18n>
|
||||
<p class="published" v-if="!post.draft">{{ post.publishAt | formatDateTimeString }}</p>
|
||||
<small v-if="post.visibility === PostVisibility.PRIVATE" class="has-text-grey">
|
||||
<b-icon icon="lock" size="is-small" />
|
||||
{{ $t("Accessible only to members", { group: post.attributedTo.name }) }}
|
||||
</small>
|
||||
<p class="buttons" v-if="isCurrentActorMember">
|
||||
<b-tag type="is-warning" size="is-medium" v-if="post.draft">{{ $t("Draft") }}</b-tag>
|
||||
<router-link
|
||||
@ -23,6 +27,7 @@
|
||||
>{{ $t("Edit") }}</router-link
|
||||
>
|
||||
</p>
|
||||
<img v-if="post.picture" :src="post.picture.url" alt="" />
|
||||
</section>
|
||||
<section v-html="post.body" class="content" />
|
||||
<section class="tags">
|
||||
@ -41,12 +46,11 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
import Editor from "@/components/Editor.vue";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "../../graphql/actor";
|
||||
import { FETCH_POST } from "../../graphql/post";
|
||||
|
||||
import { IPost } from "../../types/post.model";
|
||||
import { IMember, usernameWithDomain } from "../../types/actor";
|
||||
import { IPost, PostVisibility } from "../../types/post.model";
|
||||
import { IMember, IPerson, usernameWithDomain } from "../../types/actor";
|
||||
import RouteName from "../../router/name";
|
||||
import Tag from "../../components/Tag.vue";
|
||||
|
||||
@ -104,20 +108,24 @@ export default class Post extends Vue {
|
||||
|
||||
memberships!: IMember[];
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
PostVisibility = PostVisibility;
|
||||
|
||||
handleErrors(errors: any[]): void {
|
||||
if (errors.some((error) => error.status_code === 404)) {
|
||||
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
}
|
||||
|
||||
get isCurrentActorMember(): boolean {
|
||||
if (!this.post.attributedTo || !this.memberships) return false;
|
||||
return this.memberships.map(({ parent: { id } }) => id).includes(this.post.attributedTo.id);
|
||||
}
|
||||
|
||||
async handleErrors(errors: GraphQLError[]): Promise<void> {
|
||||
if (errors[0].message.includes("No such post")) {
|
||||
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@ -126,6 +134,10 @@ export default class Post extends Vue {
|
||||
article {
|
||||
section.heading-section {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto -3rem 2rem;
|
||||
|
||||
h1.title {
|
||||
margin: 0 auto;
|
||||
@ -145,8 +157,7 @@ article {
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 0.4rem;
|
||||
margin-bottom: 2rem;
|
||||
height: 0.2rem;
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -157,6 +168,21 @@ article {
|
||||
.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
& > * {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
& > img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.3;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
section.content {
|
||||
|
@ -198,7 +198,7 @@ export default class Register extends Vue {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.errors = error.graphQLErrors.reduce((acc: { [key: string]: any }, localError: any) => {
|
||||
acc[localError.details] = localError.message;
|
||||
acc[localError.field] = localError.message;
|
||||
return acc;
|
||||
}, {});
|
||||
this.sendingForm = false;
|
||||
|
@ -147,6 +147,7 @@ export default new VueApollo({
|
||||
"background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;",
|
||||
error.message
|
||||
);
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -35,7 +35,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Post do
|
||||
"name" => post.title,
|
||||
"content" => post.body,
|
||||
"attributedTo" => creator_url,
|
||||
"published" => (post.publish_at || post.inserted_at) |> DateTime.to_iso8601()
|
||||
"published" => (post.publish_at || post.inserted_at) |> to_date()
|
||||
}
|
||||
end
|
||||
|
||||
@ -67,4 +67,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Post do
|
||||
@spec get_actor(String.t() | map() | nil) :: {:ok, Actor.t()} | {:error, String.t()}
|
||||
defp get_actor(nil), do: {:error, "nil property found for actor data"}
|
||||
defp get_actor(actor), do: actor |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url()
|
||||
|
||||
defp to_date(%DateTime{} = date), do: DateTime.to_iso8601(date)
|
||||
defp to_date(%NaiveDateTime{} = date), do: NaiveDateTime.to_iso8601(date)
|
||||
end
|
||||
|
90
lib/graphql/error.ex
Normal file
90
lib/graphql/error.ex
Normal file
@ -0,0 +1,90 @@
|
||||
defmodule Mobilizon.GraphQL.Error do
|
||||
@moduledoc """
|
||||
Module to handle errors in GraphQL
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias __MODULE__
|
||||
import Mobilizon.Web.Gettext
|
||||
|
||||
defstruct [:code, :message, :status_code, :field]
|
||||
|
||||
# Error Tuples
|
||||
# ------------
|
||||
# Regular errors
|
||||
def normalize({:error, reason}) do
|
||||
handle(reason)
|
||||
end
|
||||
|
||||
# Ecto transaction errors
|
||||
def normalize({:error, _operation, reason, _changes}) do
|
||||
handle(reason)
|
||||
end
|
||||
|
||||
# Unhandled errors
|
||||
def normalize(other) do
|
||||
handle(other)
|
||||
end
|
||||
|
||||
# Handle Different Errors
|
||||
# -----------------------
|
||||
defp handle(code) when is_atom(code) do
|
||||
{status, message} = metadata(code)
|
||||
|
||||
%Error{
|
||||
code: code,
|
||||
message: message,
|
||||
status_code: status
|
||||
}
|
||||
end
|
||||
|
||||
defp handle(errors) when is_list(errors) do
|
||||
Enum.map(errors, &handle/1)
|
||||
end
|
||||
|
||||
defp handle(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {err, _opts} -> err end)
|
||||
|> Enum.map(fn {k, v} ->
|
||||
%Error{
|
||||
code: :validation,
|
||||
message: v,
|
||||
field: k,
|
||||
status_code: 422
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle(reason) when is_binary(reason) do
|
||||
%Error{
|
||||
code: :unknown_error,
|
||||
message: reason,
|
||||
status_code: 500
|
||||
}
|
||||
end
|
||||
|
||||
# ... Handle other error types here ...
|
||||
defp handle(other) do
|
||||
Logger.error("Unhandled error term:\n#{inspect(other)}")
|
||||
handle(:unknown)
|
||||
end
|
||||
|
||||
# Build Error Metadata
|
||||
# --------------------
|
||||
defp metadata(:unknown_resource), do: {400, "Unknown Resource"}
|
||||
defp metadata(:invalid_argument), do: {400, "Invalid arguments passed"}
|
||||
defp metadata(:unauthenticated), do: {401, "You need to be logged in"}
|
||||
defp metadata(:password_hash_missing), do: {401, "Reset your password to login"}
|
||||
defp metadata(:incorrect_password), do: {401, "Invalid credentials"}
|
||||
defp metadata(:unauthorized), do: {403, "You don't have permission to do this"}
|
||||
defp metadata(:not_found), do: {404, "Resource not found"}
|
||||
defp metadata(:user_not_found), do: {404, "User not found"}
|
||||
defp metadata(:post_not_found), do: {404, dgettext("errors", "Post not found")}
|
||||
defp metadata(:event_not_found), do: {404, dgettext("errors", "Event not found")}
|
||||
defp metadata(:unknown), do: {500, "Something went wrong"}
|
||||
|
||||
defp metadata(code) do
|
||||
Logger.warn("Unhandled error code: #{inspect(code)}")
|
||||
{422, to_string(code)}
|
||||
end
|
||||
end
|
@ -1,45 +0,0 @@
|
||||
defmodule Mobilizon.GraphQL.Helpers.Error do
|
||||
@moduledoc """
|
||||
Helper functions for Mobilizon.GraphQL
|
||||
"""
|
||||
alias Ecto.Changeset
|
||||
|
||||
def handle_errors(fun) do
|
||||
fn source, args, info ->
|
||||
case Absinthe.Resolution.call(fun, source, args, info) do
|
||||
{:error, %Changeset{} = changeset} ->
|
||||
format_changeset(changeset)
|
||||
|
||||
{:error, _, %Changeset{} = changeset} ->
|
||||
format_changeset(changeset)
|
||||
|
||||
val ->
|
||||
val
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def format_changeset(%Changeset{changes: changes} = changeset) do
|
||||
# {:error, [email: {"has already been taken", []}]}
|
||||
|
||||
errors =
|
||||
Enum.reduce(changes, [], fn {_key, value}, acc ->
|
||||
case value do
|
||||
%Changeset{} ->
|
||||
{:error, errors} = format_changeset(value)
|
||||
acc ++ errors
|
||||
|
||||
_ ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
errors = errors ++ Enum.map(changeset.errors, &transform_error/1)
|
||||
|
||||
{:error, errors}
|
||||
end
|
||||
|
||||
defp transform_error({key, {value, _context}}) do
|
||||
[message: "#{value}", details: key]
|
||||
end
|
||||
end
|
21
lib/graphql/middleware/error_handler.ex
Normal file
21
lib/graphql/middleware/error_handler.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule Mobilizon.GraphQL.Middleware.ErrorHandler do
|
||||
@moduledoc """
|
||||
Absinthe Error Handler
|
||||
"""
|
||||
alias Mobilizon.GraphQL.Error
|
||||
|
||||
@behaviour Absinthe.Middleware
|
||||
@impl true
|
||||
def call(resolution, _config) do
|
||||
errors =
|
||||
resolution.errors
|
||||
|> Enum.map(&Error.normalize/1)
|
||||
|> List.flatten()
|
||||
|> Enum.map(&to_absinthe_format/1)
|
||||
|
||||
%{resolution | errors: errors}
|
||||
end
|
||||
|
||||
defp to_absinthe_format(%Error{} = error), do: Map.from_struct(error)
|
||||
defp to_absinthe_format(error), do: error
|
||||
end
|
@ -38,12 +38,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
|
||||
{:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))}
|
||||
|
||||
{:has_event, _} ->
|
||||
{:error, dgettext("errors", "Event with UUID %{uuid} not found", uuid: uuid)}
|
||||
{:error, :event_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_private_event(_parent, %{uuid: uuid}, _resolution) do
|
||||
{:error, dgettext("errors", "Event with UUID %{uuid} not found", uuid: uuid)}
|
||||
defp find_private_event(_parent, _args, _resolution) do
|
||||
{:error, :event_not_found}
|
||||
end
|
||||
|
||||
def find_event(parent, %{uuid: uuid} = args, %{context: context} = resolution) do
|
||||
@ -57,7 +57,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
|
||||
find_private_event(parent, args, resolution)
|
||||
|
||||
{:access_valid, _} ->
|
||||
{:error, dgettext("errors", "Event with UUID %{uuid} not found", uuid)}
|
||||
{:error, :event_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
alias Mobilizon.{Actors, Posts, Users}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Federation.ActivityPub
|
||||
alias Mobilizon.Federation.ActivityPub.Utils
|
||||
alias Mobilizon.Posts.Post
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
@ -76,7 +77,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
{:ok, post}
|
||||
else
|
||||
{:member, false} -> get_post(parent, %{slug: slug}, nil)
|
||||
{:post, _} -> {:error, dgettext("errors", "No such post")}
|
||||
{:post, _} -> {:error, :post_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@ -91,12 +92,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
{:ok, post}
|
||||
|
||||
{:post, _} ->
|
||||
{:error, dgettext("errors", "No such post")}
|
||||
{:error, :post_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def get_post(_parent, _args, _resolution) do
|
||||
{:error, dgettext("errors", "No such post")}
|
||||
{:error, :post_not_found}
|
||||
end
|
||||
|
||||
def create_post(
|
||||
@ -110,6 +111,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
%Actor{} = group <- Actors.get_actor(group_id),
|
||||
args <-
|
||||
Map.update(args, :picture, nil, fn picture ->
|
||||
process_picture(picture, group)
|
||||
end),
|
||||
{:ok, _, %Post{} = post} <-
|
||||
ActivityPub.create(
|
||||
:post,
|
||||
@ -123,6 +129,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
else
|
||||
{:member, _} ->
|
||||
{:error, dgettext("errors", "Profile is not member of group")}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@ -141,8 +150,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
) do
|
||||
with {:uuid, {:ok, _uuid}} <- {:uuid, Ecto.UUID.cast(id)},
|
||||
%Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:post, %Post{attributed_to: %Actor{id: group_id}} = post} <-
|
||||
{:post, %Post{attributed_to: %Actor{id: group_id} = group} = post} <-
|
||||
{:post, Posts.get_post_with_preloads(id)},
|
||||
args <-
|
||||
Map.update(args, :picture, nil, fn picture ->
|
||||
process_picture(picture, group)
|
||||
end),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Post{} = post} <-
|
||||
ActivityPub.update(post, args, true, %{}) do
|
||||
@ -195,4 +208,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
def delete_post(_parent, _args, _resolution) do
|
||||
{:error, dgettext("errors", "You need to be logged-in to delete posts")}
|
||||
end
|
||||
|
||||
defp process_picture(nil, _), do: nil
|
||||
defp process_picture(%{picture_id: _picture_id} = args, _), do: args
|
||||
|
||||
defp process_picture(%{picture: picture}, %Actor{id: actor_id}) do
|
||||
%{
|
||||
file:
|
||||
picture
|
||||
|> Map.get(:file)
|
||||
|> Utils.make_picture_data(description: Map.get(picture, :name)),
|
||||
actor_id: actor_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -127,8 +127,8 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
|
||||
:not_allowlisted ->
|
||||
{:error, dgettext("errors", "Your email is not on the allowlist")}
|
||||
|
||||
error ->
|
||||
error
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -20,6 +20,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
||||
alias Mobilizon.Actors.{Actor, Follower, Member}
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.GraphQL.Middleware.ErrorHandler
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
alias Mobilizon.Storage.Repo
|
||||
|
||||
@ -185,4 +186,12 @@ defmodule Mobilizon.GraphQL.Schema do
|
||||
import_fields(:person_subscriptions)
|
||||
import_fields(:discussion_subscriptions)
|
||||
end
|
||||
|
||||
def middleware(middleware, _field, %{identifier: type}) when type in [:query, :mutation] do
|
||||
middleware ++ [ErrorHandler]
|
||||
end
|
||||
|
||||
def middleware(middleware, _field, _object) do
|
||||
middleware
|
||||
end
|
||||
end
|
||||
|
@ -5,7 +5,6 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||
import Mobilizon.GraphQL.Helpers.Error
|
||||
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.GraphQL.Resolvers.Person
|
||||
@ -136,7 +135,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
||||
"The banner for the profile, either as an object or directly the ID of an existing Picture"
|
||||
)
|
||||
|
||||
resolve(handle_errors(&Person.create_person/3))
|
||||
resolve(&Person.create_person/3)
|
||||
end
|
||||
|
||||
@desc "Update an identity"
|
||||
@ -157,14 +156,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
||||
"The banner for the profile, either as an object or directly the ID of an existing Picture"
|
||||
)
|
||||
|
||||
resolve(handle_errors(&Person.update_person/3))
|
||||
resolve(&Person.update_person/3)
|
||||
end
|
||||
|
||||
@desc "Delete an identity"
|
||||
field :delete_person, :person do
|
||||
arg(:id, non_null(:id))
|
||||
|
||||
resolve(handle_errors(&Person.delete_person/3))
|
||||
resolve(&Person.delete_person/3)
|
||||
end
|
||||
|
||||
@desc "Register a first profile on registration"
|
||||
@ -186,7 +185,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
||||
"The banner for the profile, either as an object or directly the ID of an existing Picture"
|
||||
)
|
||||
|
||||
resolve(handle_errors(&Person.register_person/3))
|
||||
resolve(&Person.register_person/3)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -6,7 +6,6 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||
import Mobilizon.GraphQL.Helpers.Error
|
||||
|
||||
alias Mobilizon.{Actors, Addresses, Discussions}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Event, Picture, Tag}
|
||||
@ -311,7 +310,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
|
||||
arg(:draft, :boolean, default_value: false)
|
||||
arg(:contacts, list_of(:contact), default_value: [])
|
||||
|
||||
resolve(handle_errors(&Event.create_event/3))
|
||||
resolve(&Event.create_event/3)
|
||||
end
|
||||
|
||||
@desc "Update an event"
|
||||
@ -343,7 +342,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
|
||||
arg(:draft, :boolean)
|
||||
arg(:contacts, list_of(:contact), default_value: [])
|
||||
|
||||
resolve(handle_errors(&Event.update_event/3))
|
||||
resolve(&Event.update_event/3)
|
||||
end
|
||||
|
||||
@desc "Delete an event"
|
||||
|
@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
|
||||
Schema representation for Posts
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
alias Mobilizon.GraphQL.Resolvers.{Post, Tag}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Picture, Post, Tag}
|
||||
|
||||
@desc "A post"
|
||||
object :post do
|
||||
@ -24,6 +24,11 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
|
||||
resolve: &Tag.list_tags_for_post/3,
|
||||
description: "The post's tags"
|
||||
)
|
||||
|
||||
field(:picture, :picture,
|
||||
description: "The event's picture",
|
||||
resolve: &Picture.picture/3
|
||||
)
|
||||
end
|
||||
|
||||
object :paginated_post_list do
|
||||
@ -55,7 +60,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
|
||||
field :create_post, :post do
|
||||
arg(:attributed_to_id, non_null(:id))
|
||||
arg(:title, non_null(:string))
|
||||
arg(:body, :string)
|
||||
arg(:body, non_null(:string))
|
||||
arg(:draft, :boolean, default_value: false)
|
||||
arg(:visibility, :post_visibility)
|
||||
arg(:publish_at, :datetime)
|
||||
@ -65,6 +70,11 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
|
||||
description: "The list of tags associated to the post"
|
||||
)
|
||||
|
||||