Made profile header component that supports uploading and displaying a header image and added it to the profile page. Added locales in all languages for a succesful or failed upload toast. Included test for the new userProfileHeader component. Updated the gql model to include the profileHeader.

This commit is contained in:
Dries Cruyskens 2020-06-12 18:28:40 +02:00
parent a551959b79
commit f62efe39fa
13 changed files with 490 additions and 1 deletions

View File

@ -0,0 +1,224 @@
<template>
<div class="profile-header">
<vue-dropzone
v-if="user && this.editable"
id="profileHeaderDropzone"
:key="profileHeaderUrl"
ref="el"
:use-custom-slot="true"
:options="dropzoneOptions"
@vdropzone-error="verror"
>
<div class="dz-message" @mouseover="hover = true" @mouseleave="hover = false">
<img
v-if="user && user.profileHeader"
:src="user.profileHeader | proxyApiUrl"
@error="$event.target.style.display = 'none'"
class="profile-header-image"
:alt="imageAlt"
/>
<div class="profileHeader-attachments-upload-area">
<div class="profileHeader-drag-marker">
<base-icon v-if="hover" name="image" />
</div>
</div>
</div>
</vue-dropzone>
<div v-else>
<img
v-if="user && user.profileHeader"
:src="user.profileHeader | proxyApiUrl"
@error="$event.target.style.display = 'none'"
class="profile-header-image"
:alt="imageAlt"
/>
</div>
</div>
</template>
<script>
import vueDropzone from 'nuxt-dropzone'
import { updateUserMutation } from '~/graphql/User.js'
export default {
name: 'UserProfileHeader',
components: {
vueDropzone,
},
props: {
user: {
type: Object,
default: null,
},
editable: {
type: Boolean,
default: false,
},
},
data() {
return {
dropzoneOptions: {
url: this.vddrop,
maxFilesize: 5.0,
previewTemplate: this.template(),
},
error: false,
hover: false,
}
},
computed: {
imageAlt() {
if (!this.user && !this.user.name) return 'Profile header image'
return 'Profile header image of ' + this.user.name
},
profileHeaderUrl() {
const { profileHeader } = this.user
return profileHeader && profileHeader.url
},
},
watch: {
error() {
const that = this
setTimeout(function () {
that.error = false
}, 2000)
},
},
methods: {
template() {
return `<div class="dz-preview dz-file-preview">
<div class="dz-image">
<div data-dz-thumbnail-bg></div>
</div>
</div>
`
},
vddrop(file) {
const profileHeaderUpload = file[0]
this.$apollo
.mutate({
mutation: updateUserMutation(),
variables: {
profileHeader: {
upload: profileHeaderUpload,
},
id: this.user.id,
},
})
.then(() => {
this.$toast.success(this.$t('user.profileHeader.submitted'))
})
.catch((error) => this.$toast.error(error.message))
},
verror(file, message) {
if (file.status === 'error') {
this.error = true
this.$toast.error(file.status, message)
}
},
},
}
</script>
<style lang="scss">
.profile-header {
height: 100%;
position: relative;
background-color: DarkGrey; /* Fallback color */
}
.profile-header-image {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
}
#profileHeaderDropzone {
height: 100%;
}
.dz-message {
height: 100%;
}
#profileHeaderDropzone .dz-preview {
transition: all 0.2s ease-out;
width: 160px;
display: flex;
}
#profileHeaderDropzone .dz-preview .dz-image {
width: 100%;
height: 100%;
object-fit: contain;
overflow: hidden;
}
#profileHeaderDropzone .dz-preview .dz-image > div {
width: inherit;
height: inherit;
border-radius: 50%;
background-size: cover;
}
#profileHeaderDropzone .dz-preview .dz-image > img {
width: 100%;
}
.profileHeader-attachments-upload-area {
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.profileHeader-attachments-upload-button {
pointer-events: none;
}
.profileHeader-drag-marker {
position: relative;
width: 122px;
height: 122px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
color: hsl(0, 0%, 25%);
transition: all 0.2s ease-out;
font-size: 60px;
background-color: rgba(255, 255, 255, 0.7);
opacity: 0.1;
&:before {
position: absolute;
content: '';
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 100%;
border: 20px solid rgba(255, 255, 255, 0.4);
visibility: hidden;
}
&:after {
position: absolute;
content: '';
top: 10px;
left: 10px;
bottom: 10px;
right: 10px;
border-radius: 100%;
border: 1px dashed hsl(0, 0%, 25%);
}
.profileHeader-attachments-upload-area:hover & {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,217 @@
import { mount, shallowMount } from '@vue/test-utils'
import UserProfileHeader from './UserProfileHeader.vue'
import vueDropzone from 'nuxt-dropzone'
import Vue from 'vue'
const localVue = global.localVue
describe('UserProfileHeader.vue', () => {
let propsData, wrapper, mocks
beforeEach(() => {
propsData = {}
wrapper = Wrapper()
})
const Wrapper = () => {
return mount(UserProfileHeader, { propsData, localVue })
}
it('renders no image', () => {
expect(wrapper.contains('img')).toBe(false)
})
it('renders no dropzone', () => {
expect(wrapper.contains(vueDropzone)).toBe(false)
})
describe('given a user', () => {
describe('with no header image', () => {
beforeEach(() => {
propsData = {
user: {
name: 'Matt Rider',
},
}
wrapper = Wrapper()
})
it('renders no img tag', () => {
expect(wrapper.contains('img')).toBe(false)
})
})
describe('with a header image', () => {
beforeEach(() => {
propsData = {
user: {
name: 'Matt Rider',
profileHeader: {
url: 'https://source.unsplash.com/640x480',
},
},
}
wrapper = Wrapper()
})
it('renders an image', () => {
expect(wrapper.contains('img')).toBe(true)
})
})
describe('with a relative avatar url', () => {
beforeEach(() => {
propsData = {
user: {
name: 'Not Anonymous',
profileHeader: {
url: '/profileHeader.jpg',
},
},
}
wrapper = Wrapper()
})
it('adds a prefix to load the image from the uploads service', () => {
expect(wrapper.find('img').attributes('src')).toBe('/api/profileHeader.jpg')
})
})
describe('with an absolute avatar url', () => {
beforeEach(() => {
propsData = {
user: {
name: 'Not Anonymous',
profileHeader: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
},
},
}
wrapper = Wrapper()
})
it('keeps the avatar URL as is', () => {
// e.g. our seeds have absolute image URLs
expect(wrapper.find('img').attributes('src')).toBe(
'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
)
})
})
describe('on his own userpage', () => {
beforeEach(() => {
propsData = {
editable: true,
user: {
name: 'Not Anonymous',
profileHeader: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
},
},
}
wrapper = Wrapper()
})
it('a dropzone is present', () => {
expect(wrapper.contains(vueDropzone)).toBe(true)
})
describe('uploading and image', () => {
beforeAll(() => {
propsData = {
user: {
profileHeader: { url: '/api/profileHeader.jpg' },
},
}
mocks = {
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
UpdateUser: {
id: 'upload1',
profileHeader: { url: '/upload/profileHeader.jpg' },
},
},
})
.mockRejectedValue({
message: 'File upload unsuccessful!',
}),
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$t: jest.fn(),
}
})
beforeEach(() => {
jest.useFakeTimers()
wrapper = shallowMount(UserProfileHeader, { localVue, propsData, mocks })
})
afterEach(() => {
jest.clearAllMocks()
})
it('sends a UpdateUser mutation when vddrop is called', () => {
wrapper.vm.vddrop([{ filename: 'profileHeader.jpg' }])
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
describe('error handling', () => {
const message = 'File upload failed'
const fileError = { status: 'error' }
it('defaults to error false', () => {
expect(wrapper.vm.error).toEqual(false)
})
it('shows an error toaster when verror is called', () => {
wrapper.vm.verror(fileError, message)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
})
it('changes error status from false to true to false', async () => {
wrapper.vm.verror(fileError, message)
await Vue.nextTick()
expect(wrapper.vm.error).toEqual(true)
jest.runAllTimers()
expect(wrapper.vm.error).toEqual(false)
})
it('shows an error toaster when the apollo mutation rejects', async () => {
// calls vddrop twice because of how mockResolvedValueOnce works in jest
// the first time the mock function is called it will resolve, calling it a
// second time will cause it to fail(with this implementation)
// https://jestjs.io/docs/en/mock-function-api.html#mockfnmockresolvedvalueoncevalue
await wrapper.vm.vddrop([{ filename: 'profileHeader.jpg' }])
await wrapper.vm.vddrop([{ filename: 'profileHeader.jpg' }])
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
})
})
})
describe("on a different user's userpage", () => {
beforeEach(() => {
propsData = {
editable: false,
user: {
name: 'Not Anonymous',
profileHeader: {
url: 'https://s3.amazonaws.com/uifaces/faces/twitter/sawalazar/128.jpg',
},
},
}
wrapper = Wrapper()
})
it('no dropzone is present', () => {
expect(wrapper.contains(vueDropzone)).toBe(false)
})
})
})
})

View File

@ -20,6 +20,9 @@ export default (i18n) => {
...userCounts
...locationAndBadges
about
profileHeader {
url
}
locationName
createdAt
followedByCurrentUser
@ -226,6 +229,7 @@ export const updateUserMutation = () => {
$showShoutsPublicly: Boolean
$termsAndConditionsAgreedVersion: String
$avatar: ImageInput
$profileHeader: ImageInput
) {
UpdateUser(
id: $id
@ -237,6 +241,7 @@ export const updateUserMutation = () => {
showShoutsPublicly: $showShoutsPublicly
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatar: $avatar
profileHeader: $profileHeader
) {
id
slug
@ -250,6 +255,9 @@ export const updateUserMutation = () => {
avatar {
url
}
profileHeader {
url
}
}
}
`

View File

@ -830,6 +830,9 @@
"user": {
"avatar": {
"submitted": "Erfolgreich hochgeladen!"
},
"profileHeader": {
"submitted": "Erfolgreich hochgeladen!"
}
}
}

View File

@ -830,6 +830,9 @@
"user": {
"avatar": {
"submitted": "Upload successful!"
},
"profileHeader": {
"submitted": "Upload successful!"
}
}
}

View File

@ -827,6 +827,9 @@
"user": {
"avatar": {
"submitted": "Carga con éxito"
},
"profileHeader": {
"submitted": "Carga con éxito!"
}
}
}

View File

@ -791,6 +791,9 @@
"user": {
"avatar": {
"submitted": "Téléchargement réussi"
},
"profileHeader": {
"submitted": "Téléchargement réussi"
}
}
}

View File

@ -734,6 +734,9 @@
"user": {
"avatar": {
"submitted": ""
},
"profileHeader": {
"submitted": "Caricamento eseguito correttamente!"
}
}
}

View File

@ -172,5 +172,10 @@
"taxident": "Identificatienummer voor de belasting over de toegevoegde waarde overeenkomstig § 27 a Wet op de belasting over de toegevoegde waarde (Duitsland).",
"termsAc": "Gebruiksvoorwaarden",
"tribunal": "registerrechtbank"
},
"user": {
"profileHeader": {
"submitted": "Uploaden geslaagd!"
}
}
}

View File

@ -363,6 +363,9 @@
"user": {
"avatar": {
"submitted": "Przesłano pomyślnie"
},
"profileHeader": {
"submitted": "Przesłano pomyślnie"
}
}
}

View File

@ -722,6 +722,9 @@
"user": {
"avatar": {
"submitted": "Carregado com sucesso!"
},
"profileHeader": {
"submitted": "Carregado com sucesso!"
}
}
}

View File

@ -823,6 +823,9 @@
"user": {
"avatar": {
"submitted": "Успешная загрузка!"
},
"profileHeader": {
"submitted": "Успешная загрузка!"
}
}
}

View File

@ -1,7 +1,11 @@
<template>
<div>
<ds-space />
<ds-flex v-if="user" :width="{ base: '100%' }" gutter="base">
<ds-flex-item width="100%">
<base-card class="profile-header">
<user-profile-header :user="user" :editable="myProfile"></user-profile-header>
</base-card>
</ds-flex-item>
<ds-flex-item :width="{ base: '100%', sm: 2, md: 2, lg: 1 }">
<base-card
:class="{ 'disabled-content': user.disabled }"
@ -242,6 +246,7 @@ import { muteUser, unmuteUser } from '~/graphql/settings/MutedUsers'
import { blockUser, unblockUser } from '~/graphql/settings/BlockedUsers'
import PostMutations from '~/graphql/PostMutations'
import UpdateQuery from '~/components/utils/UpdateQuery'
import UserProfileHeader from '~/components/_new/generic/UserProfileHeader/UserProfileHeader'
const tabToFilterMapping = ({ tab, id }) => {
return {
@ -264,6 +269,7 @@ export default {
MasonryGrid,
MasonryGridItem,
FollowList,
UserProfileHeader,
},
transition: {
name: 'slide-up',
@ -543,4 +549,9 @@ export default {
margin-bottom: $space-x-small;
}
}
.profile-header {
padding: 0px; /* Overwrite default card padding to 0. */
height: 250px;
}
</style>