Fix posts and rework graphql errors

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-10-01 15:07:15 +02:00
parent 92367a5f33
commit aced4d039b
69 changed files with 1795 additions and 999 deletions

View File

@@ -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() &&

View 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>

View File

@@ -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 };

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 });
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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);
},
});