Merge branch 'master' into script-setup

This commit is contained in:
mutoe 2021-02-28 01:17:45 +08:00
commit 9fe0e3770a
53 changed files with 935 additions and 284 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@ -20,5 +20,14 @@
"comma-dangle": ["warn", "always-multiline"],
"@typescript-eslint/promise-function-async": "off",
"@typescript-eslint/no-unused-vars": "off"
}
},
"overrides": [
{
"files": [ "src/**/*.spec.ts" ],
"rules": {
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off"
}
}
]
}

View File

@ -12,10 +12,11 @@ jobs:
steps:
- uses: actions/checkout@master
- name: Use Node.js 12.x
# https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 12.x
node-version: 14.x
- name: Get yarn cache
id: yarn-cache
@ -31,11 +32,14 @@ jobs:
- name: Install dependencies
run: yarn install --skip-integrity-check --non-interactive --no-progress
- name: Lint
run: |
yarn tsc
yarn lint
yarn lint:vti
- name: TypeScript check
run: yarn tsc
- name: Eslint check
run: yarn lint:script
- name: Vetur check
run: yarn lint:vti
- name: Unit test
run: yarn test:unit

View File

@ -1,5 +1,8 @@
# ![RealWorld Example App](logo.png)
![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Node%20CI/master?label=master%20branch&logo=github&style=for-the-badge)
![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Node%20CI/script-setup?label=script%20setup%20branch&logo=github&style=for-the-badge)
> ### [Vue3](https://v3.vuejs.org/) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.
@ -24,7 +27,7 @@ For more information on how to this works with other frontends/backends, head ov
- [x] Unit test ([Vue Test Utils](https://github.com/vuejs/vue-test-utils-next)) (master branch)
- [x] Unit test ([Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro)) (in [testing-library branch](https://github.com/mutoe/vue3-realworld-example-app/tree/testing-library))
- [x] E2E test ([Cypress](https://docs.cypress.io))
- [x] [Script setup](https://github.com/vuejs/rfcs/blob/sfc-improvements/active-rfcs/0000-sfc-script-setup.md) (in [script-setup branch](https://github.com/mutoe/vue3-realworld-example-app/tree/script-setup)
- [x] [Script setup](https://github.com/vuejs/rfcs/blob/sfc-improvements/active-rfcs/0000-sfc-script-setup.md) (in [script-setup branch](https://github.com/mutoe/vue3-realworld-example-app/tree/script-setup))
- [x] Vetur Tools: [VTI](https://github.com/mutoe/vue3-realworld-example-app/pull/28) and [optionally IDE hints](https://github.com/mutoe/vue3-realworld-example-app/commit/8367f89a99c467d181d9c7f4144deb05cec55210#commitcomment-43957089)
> \* "Experimental" means this feature may be changed.
@ -57,7 +60,7 @@ yarn test:e2e # headless
Made with [contributors-img](https://contributors-img.web.app).
## Vue related implementations of the Realworld app
[gothinkster/vue-realworld-example-app](https://github.com/gothinkster/vue-realworld-example-app) - vue2, js
[AlexBrohshtut/vue-ts-realworld-app](https://github.com/AlexBrohshtut/vue-ts-realworld-app) - vue2, ts, class-component
[devJang/nuxt-realworld](https://github.com/devJang/nuxt-realworld) - nuxt, ts, composition api
[levchak0910/vue3-ssr-realworld-example-app](https://github.com/levchak0910/vue3-ssr-realworld-example-app) - vue3, ssr
[gothinkster/vue-realworld-example-app](https://github.com/gothinkster/vue-realworld-example-app) - vue2, js
[AlexBrohshtut/vue-ts-realworld-app](https://github.com/AlexBrohshtut/vue-ts-realworld-app) - vue2, ts, class-component
[devJang/nuxt-realworld](https://github.com/devJang/nuxt-realworld) - nuxt, ts, composition api
[levchak0910/vue3-ssr-realworld-example-app](https://github.com/levchak0910/vue3-ssr-realworld-example-app) - vue3, ssr

View File

@ -7,7 +7,7 @@
"build": "vite build",
"lint:script": "eslint \"{src,cypress}/**/*.{js,ts,vue}\"",
"lint:vti": "vti diagnostics",
"lint": "yarn tsc && yarn lint:script && yarn lint:vti",
"lint": "concurrently 'yarn tsc' 'yarn lint:script' 'yarn lint:vti'",
"test:unit": "jest --coverage",
"test:e2e": "yarn build && concurrently -k \"serve dist\" \"cypress run -c baseUrl=http://localhost:5000\"",
"test:e2e:ci": "cypress run -C cypress.prod.json",

View File

@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import AppFooter from './AppFooter.vue'
import registerGlobalComponents from '../plugins/global-components'
import { router } from '../router'
describe('# AppFooter', () => {
beforeEach(async () => {

View File

@ -1,6 +1,6 @@
import AppLink from './AppLink.vue'
import { router } from '../router'
import { flushPromises, mount } from '@vue/test-utils'
import { router } from 'src/router'
import AppLink from './AppLink.vue'
describe('# AppLink', function () {
beforeEach(async () => {

View File

@ -9,11 +9,9 @@
</template>
<script lang="ts" setup>
import type { AppRouteNames } from '../router'
import type { RouteParams } from 'vue-router'
import { RouterLink } from 'vue-router'
import type { AppRouteNames } from 'src/router'
import { defineProps, useContext } from 'vue'
import type { RouteParams } from 'vue-router'
const props = defineProps<{
name: AppRouteNames

View File

@ -0,0 +1,39 @@
import { mount } from '@vue/test-utils'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { updateUser, user } from 'src/store/user'
import AppNavigation from './AppNavigation.vue'
describe('# AppNavigation', () => {
beforeEach(async () => {
updateUser(null)
await router.push('/')
})
it('should render Sign in and Sign up when user not logged', () => {
const wrapper = mount(AppNavigation, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(wrapper.findAll('.nav-item')).toHaveLength(3)
expect(wrapper.text()).toContain('Home')
expect(wrapper.text()).toContain('Sign in')
expect(wrapper.text()).toContain('Sign up')
})
it('should render xxx when user logged', () => {
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
const wrapper = mount(AppNavigation, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
})
expect(wrapper.findAll('.nav-item')).toHaveLength(4)
expect(wrapper.text()).toContain('Home')
expect(wrapper.text()).toContain('New Post')
expect(wrapper.text()).toContain('Settings')
expect(wrapper.text()).toContain('foo')
})
})

View File

@ -1,17 +1,29 @@
<template>
<nav class="navbar navbar-light">
<div class="container">
<AppLink class="navbar-brand" name="global-feed"> conduit </AppLink>
<AppLink
class="navbar-brand"
name="global-feed"
>
conduit
</AppLink>
<ul class="nav navbar-nav pull-xs-right">
<li v-for="link in navLinks" :key="link.name" class="nav-item">
<li
v-for="link in navLinks"
:key="link.name"
class="nav-item"
>
<AppLink
class="nav-link"
active-class="active"
:name="link.name"
:params="link.params"
>
<i v-if="link.icon" :class="link.icon" /> {{ link.title }}
<i
v-if="link.icon"
:class="link.icon"
/> {{ link.title }}
</AppLink>
</li>
</ul>
@ -20,11 +32,10 @@
</template>
<script lang="ts" setup>
import type { AppRouteNames } from 'src/router'
import { user } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import type { RouteParams } from 'vue-router'
import type { AppRouteNames } from '../router'
import { computed } from 'vue'
import { user } from '../store/user'
interface NavLink {
name: AppRouteNames

View File

@ -0,0 +1,26 @@
import { shallowMount } from '@vue/test-utils'
import AppPagination from './AppPagination.vue'
describe('# AppPagination', () => {
it('should highlight current active page', () => {
const wrapper = shallowMount(AppPagination, {
props: { page: 1, count: 15 },
})
const pageItems = wrapper.findAll('.page-item')
expect(pageItems).toHaveLength(2)
expect(pageItems[0].classes()).toContain('active')
})
it('should call onPageChange when click a page item', async () => {
const wrapper = shallowMount(AppPagination, {
props: { page: 1, count: 15 },
})
await wrapper.find('a[aria-label="Go to page 2"]').trigger('click')
const events = wrapper.emitted('page-change')
expect(events).toHaveLength(1)
expect(events?.[0]).toEqual([2])
})
})

View File

@ -6,6 +6,7 @@
:class="['page-item', { active: isActive(pageNumber) }]"
>
<a
:aria-label="`Go to page ${pageNumber}`"
class="page-link"
@click="onPageChange(pageNumber)"
>{{ pageNumber }}</a>
@ -14,13 +15,14 @@
</template>
<script lang="ts" setup>
import { limit } from 'src/services'
import { computed, defineEmit, defineProps, toRefs } from 'vue'
import { limit } from '../services'
const props = defineProps<{
page: number
count: number
}>()
const emit = defineEmit<(e: 'page-change', index: number) => void>()
const { count, page } = toRefs(props)

View File

@ -0,0 +1,32 @@
import { flushPromises, mount } from '@vue/test-utils'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getArticle } from 'src/services/article/getArticle'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetail from './ArticleDetail.vue'
jest.mock('src/services/article/getArticle')
describe('# ArticleDetail', () => {
const mockGetArticle = getArticle as jest.MockedFunction<typeof getArticle>
beforeEach(async () => {
mockGetArticle.mockResolvedValue(fixtures.article)
await router.push({
name: 'article',
params: { slug: fixtures.article.slug },
})
})
it('should render markdown body correctly', async () => {
const wrapper = mount(asyncComponentWrapper(ArticleDetail), {
global: { plugins: [registerGlobalComponents, router] },
})
await flushPromises()
const articleBody = wrapper.find('.article-content')
expect(articleBody.find('h1').text()).toEqual('Article body')
expect(articleBody.find('strong').text()).toEqual('Strong')
})
})

View File

@ -3,15 +3,23 @@
<div class="container">
<h1>{{ article.title }}</h1>
<ArticleDetailMeta :article="article" @update="updateArticle" />
<ArticleDetailMeta
:article="article"
@update="updateArticle"
/>
</div>
</div>
<div class="container page">
<div class="row article-content">
<!-- eslint-disable vue/no-v-html -->
<div class="col-md-12" v-html="articleHandledBody" />
<div
class="col-md-12"
v-html="articleHandledBody"
/>
<!-- eslint-enable vue/no-v-html -->
<!-- TODO: abstract tag list component-->
<ul class="tag-list">
<li
v-for="tag in article.tagList"
@ -23,10 +31,13 @@
</ul>
</div>
<hr />
<hr>
<div class="article-actions">
<ArticleDetailMeta :article="article" @update="updateArticle" />
<ArticleDetailMeta
:article="article"
@update="updateArticle"
/>
</div>
</div>
</template>
@ -34,17 +45,15 @@
<script lang="ts" setup>
import DOMPurify from 'dompurify'
import md2html from 'marked'
import { getArticle } from 'src/services/article/getArticle'
import { computed, reactive } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
import { getArticle } from '../services/article/getArticle'
const route = useRoute()
const slug = route.params.slug as string
const article = reactive<Article>(await getArticle(slug))
const articleHandledBody = computed(() => md2html(article.body, { sanitizer: DOMPurify.sanitize }))
const updateArticle = (newArticle: Article) => {
Object.assign(article, newArticle)
}

View File

@ -0,0 +1,45 @@
import { shallowMount } from '@vue/test-utils'
import registerGlobalComponents from 'src/plugins/global-components'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailComment from './ArticleDetailComment.vue'
describe('# ArticleDetailComment', () => {
const deleteButton = '[role=button][aria-label="Delete comment"]'
it('should render correctly', () => {
const wrapper = shallowMount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents] },
props: { comment: fixtures.comment },
})
expect(wrapper.find('.card-text').text()).toEqual('Comment body')
expect(wrapper.find('.date-posted').text()).toEqual('1/1/2020')
expect(wrapper.find(deleteButton).exists()).toBe(false)
})
it('should delete comment button when comment author is same user', () => {
const wrapper = shallowMount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents] },
props: {
comment: fixtures.comment,
username: fixtures.author.username,
},
})
expect(wrapper.find(deleteButton).exists()).toBe(true)
})
it('should emit remove comment when click remove comment button', async () => {
const wrapper = shallowMount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents] },
props: { comment: fixtures.comment, username: fixtures.author.username },
})
await wrapper.find(deleteButton).trigger('click')
const events = wrapper.emitted('remove-comment')
expect(events).toHaveLength(1)
expect(events![0]).toEqual([])
})
})

View File

@ -33,6 +33,8 @@
<span class="mod-options">
<i
v-if="showRemove"
role="button"
aria-label="Delete comment"
class="ion-trash-a"
@click="$emit('remove-comment')"
/>

View File

@ -0,0 +1,66 @@
import { flushPromises, mount } from '@vue/test-utils'
import ArticleDetailComment from 'src/components/ArticleDetailComment.vue'
import ArticleDetailCommentsForm from 'src/components/ArticleDetailCommentsForm.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import { nextTick } from 'vue'
import ArticleDetailComments from './ArticleDetailComments.vue'
jest.mock('src/services/comment/getComments')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailComments', () => {
const mockGetCommentsByArticle = getCommentsByArticle as jest.MockedFunction<typeof getCommentsByArticle>
const mockDeleteComment = deleteComment as jest.MockedFunction<typeof deleteComment>
beforeEach(async () => {
mockGetCommentsByArticle.mockResolvedValue([fixtures.comment])
await router.push({
name: 'article',
params: { slug: fixtures.article.slug },
})
})
it('should render correctly', async () => {
const wrapper = mount(asyncComponentWrapper(ArticleDetailComments), {
global: { plugins: [registerGlobalComponents, router] },
})
expect(mockGetCommentsByArticle).toBeCalledWith('article-foo')
expect(wrapper).toBeTruthy()
})
it('should display new comment when post new comment', async () => {
// given
const wrapper = mount(asyncComponentWrapper(ArticleDetailComments), {
global: { plugins: [registerGlobalComponents, router] },
})
await flushPromises()
expect(wrapper.findAll('.card')).toHaveLength(1)
// when
wrapper.findComponent(ArticleDetailCommentsForm).vm.$emit('add-comment', fixtures.comment2)
await nextTick()
// then
expect(wrapper.findAll('.card')).toHaveLength(2)
})
it('should call remove comment service when click delete button', async () => {
// given
const wrapper = mount(asyncComponentWrapper(ArticleDetailComments), {
global: { plugins: [registerGlobalComponents, router] },
})
await flushPromises()
// when
wrapper.findComponent(ArticleDetailComment).vm.$emit('remove-comment')
// then
expect(mockDeleteComment).toBeCalledWith('article-foo', 1)
})
})

View File

@ -14,16 +14,13 @@
</template>
<script lang="ts" setup>
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import { user } from 'src/store/user'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailCommentsForm from './ArticleDetailCommentsForm.vue'
import ArticleDetailComment from './ArticleDetailComment.vue'
import { getCommentsByArticle } from '../services/comment/getComments'
import { deleteComment } from '../services/comment/postComment'
import { user } from '../store/user'
import ArticleDetailCommentsForm from './ArticleDetailCommentsForm.vue'
const route = useRoute()
const slug = route.params.slug as string
@ -42,5 +39,4 @@ const removeComment = async (commentId: number) => {
}
comments.value = await getCommentsByArticle(slug)
</script>

View File

@ -0,0 +1,56 @@
import { DOMWrapper, flushPromises, shallowMount } from '@vue/test-utils'
import ArticleDetailCommentsForm from 'src/components/ArticleDetailCommentsForm.vue'
import { useProfile } from 'src/composable/useProfile'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { postComment } from 'src/services/comment/postComment'
import fixtures from 'src/utils/test/fixtures'
import { ref } from 'vue'
jest.mock('src/composable/useProfile')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailCommentsForm', () => {
const mockUseProfile = useProfile as jest.MockedFunction<typeof useProfile>
const mockPostComment = postComment as jest.MockedFunction<typeof postComment>
beforeEach(() => {
mockPostComment.mockResolvedValue(fixtures.comment2)
mockUseProfile.mockReturnValue({
profile: ref(fixtures.author),
updateProfile: jest.fn(),
})
})
it('should display sign in button when user not logged', () => {
mockUseProfile.mockReturnValue({ profile: ref(null), updateProfile: jest.fn() })
const wrapper = shallowMount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents] },
props: { articleSlug: fixtures.article.slug },
})
expect(wrapper.text()).toContain('add comments on this article')
})
it('should display form when user logged', async () => {
// given
const wrapper = shallowMount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents, router] },
props: { articleSlug: fixtures.article.slug },
})
// when
const inputElement = wrapper.find('textarea[aria-label="Write comment"]') as DOMWrapper<HTMLTextAreaElement>
inputElement.element.value = 'some texts...'
await inputElement.trigger('input')
await wrapper.find('form').trigger('submit')
await flushPromises()
// then
expect(mockPostComment).toBeCalledWith('article-foo', 'some texts...')
const events = wrapper.emitted('add-comment')
expect(events).toHaveLength(1)
expect(events![0]).toEqual([fixtures.comment2])
})
})

View File

@ -14,6 +14,7 @@
<div class="card-block">
<textarea
v-model="comment"
aria-label="Write comment"
class="form-control"
placeholder="Write a comment..."
rows="3"
@ -25,8 +26,9 @@
class="comment-author-img"
>
<button
aria-label="Submit"
type="submit"
:disabled="comment===''"
:disabled="comment === ''"
class="btn btn-sm btn-primary"
>
Post Comment
@ -36,13 +38,10 @@
</template>
<script lang="ts" setup>
import { ref, computed, defineProps, defineEmit } from 'vue'
import { useProfile } from '../composable/useProfile'
import { postComment } from '../services/comment/postComment'
import { user, checkAuthorization } from '../store/user'
import { useProfile } from 'src/composable/useProfile'
import { postComment } from 'src/services/comment/postComment'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineEmit, defineProps, ref } from 'vue'
const props = defineProps<{
articleSlug: string
@ -60,5 +59,4 @@ const submitComment = async () => {
emit('add-comment', newComment)
comment.value = ''
}
</script>

View File

@ -0,0 +1,134 @@
import { mount } from '@vue/test-utils'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { deleteArticle } from 'src/services/article/deleteArticle'
import { deleteFavoriteArticle, postFavoriteArticle } from 'src/services/article/favoriteArticle'
import { deleteFollowProfile, postFollowProfile } from 'src/services/profile/followProfile'
import { updateUser, user } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
jest.mock('src/services/article/deleteArticle')
jest.mock('src/services/profile/followProfile')
jest.mock('src/services/article/favoriteArticle')
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
}
describe('# ArticleDetailMeta', () => {
const editButton = '[aria-label="Edit article"]'
const deleteButton = '[aria-label="Delete article"]'
const followButton = '[aria-label="Follow"]'
const unfollowButton = '[aria-label="Unfollow"]'
const favoriteButton = '[aria-label="Favorite article"]'
const unfavoriteButton = '[aria-label="Unfavorite article"]'
const mockDeleteArticle = deleteArticle as jest.MockedFunction<typeof deleteArticle>
const mockFollowUser = postFollowProfile as jest.MockedFunction<typeof postFollowProfile>
const mockUnfollowUser = deleteFollowProfile as jest.MockedFunction<typeof deleteFollowProfile>
const mockFavoriteArticle = postFavoriteArticle as jest.MockedFunction<typeof postFavoriteArticle>
const mockUnfavoriteArticle = deleteFavoriteArticle as jest.MockedFunction<typeof deleteFavoriteArticle>
beforeEach(async () => {
mockFollowUser.mockResolvedValue({ isOk: () => true } as any)
mockUnfollowUser.mockResolvedValue({ isOk: () => true } as any)
mockFavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
mockUnfavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
updateUser(fixtures.user)
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
})
it('should display edit button when user is author', () => {
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: fixtures.article },
})
expect(wrapper.find(editButton).exists()).toBe(true)
expect(wrapper.find(followButton).exists()).toBe(false)
})
it('should display follow button when user not author', () => {
updateUser({ ...fixtures.user, username: 'user2' })
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: fixtures.article },
})
expect(wrapper.find(editButton).exists()).toBe(false)
expect(wrapper.find(followButton).exists()).toBe(true)
})
it('should not display follow button and edit button when user not logged', () => {
updateUser(null)
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: fixtures.article },
})
expect(wrapper.find(editButton).exists()).toBe(false)
expect(wrapper.find(followButton).exists()).toBe(false)
})
it('should call delete article service when click delete button', async () => {
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: fixtures.article },
})
await wrapper.find(deleteButton).trigger('click')
expect(mockDeleteArticle).toBeCalledWith('article-foo')
})
it('should call follow service when click follow button', async () => {
updateUser({ ...fixtures.user, username: 'user2' })
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: fixtures.article },
})
await wrapper.find(followButton).trigger('click')
expect(mockFollowUser).toBeCalledWith('Author name')
})
it('should call unfollow service when click follow button and not followed author', async () => {
updateUser({ ...fixtures.user, username: 'user2' })
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: { ...fixtures.article, author: { ...fixtures.article.author, following: true } } },
})
await wrapper.find(unfollowButton).trigger('click')
expect(mockUnfollowUser).toBeCalledWith('Author name')
})
it('should call favorite article service when click favorite button', async () => {
updateUser({ ...fixtures.user, username: 'user2' })
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: { ...fixtures.article, favorited: false } },
})
await wrapper.find(favoriteButton).trigger('click')
expect(mockFavoriteArticle).toBeCalledWith('article-foo')
})
it('should call favorite article service when click unfavorite button', async () => {
updateUser({ ...fixtures.user, username: 'user2' })
const wrapper = mount(ArticleDetailMeta, {
global: globalMountOptions,
props: { article: { ...fixtures.article, favorited: true } },
})
await wrapper.find(unfavoriteButton).trigger('click')
expect(mockUnfavoriteArticle).toBeCalledWith('article-foo')
})
})

View File

@ -1,56 +1,60 @@
<template>
<div class="article-meta">
<AppLink name="profile" :params="{ username: article.author.username }">
<img :src="article.author.image" />
<AppLink
name="profile"
:params="{username: article.author.username}"
>
<img :src="article.author.image">
</AppLink>
<div class="info">
<AppLink
name="profile"
:params="{ username: article.author.username }"
:params="{username: article.author.username}"
class="author"
>
{{ article.author.username }}
</AppLink>
<span class="date">{{
new Date(article.createdAt).toLocaleDateString()
}}</span>
<span class="date">{{ (new Date(article.createdAt)).toLocaleDateString() }}</span>
</div>
<button
v-if="displayFollowButton"
:aria-label="article.author.following ? 'Unfollow' : 'Follow'"
class="btn btn-sm btn-outline-secondary space"
:disabled="followProcessGoing"
@click="toggleFollow"
>
<i class="ion-plus-round space" />
{{ article.author.following ? "Unfollow" : "Follow" }}
{{ article.author.username }}
{{ article.author.following ? "Unfollow" : "Follow" }} {{ article.author.username }}
</button>
<button
:aria-label="article.favorited ? 'Unfavorite article' : 'Favorite article'"
class="btn btn-sm space"
:class="[article.favorited ? 'btn-primary' : 'btn-outline-primary']"
:class="[article.favorited ? 'btn-primary':'btn-outline-primary']"
:disabled="favoriteProcessGoing"
@click="favoriteArticle"
>
<i class="ion-heart space" />
{{ article.favorited ? "Unfavorite" : "Favorite" }} Article
{{ article.favorited ? 'Unfavorite' : 'Favorite' }} Article
<span class="counter">({{ article.favoritesCount }})</span>
</button>
<AppLink
v-if="displayEditButton"
aria-label="Edit article"
class="btn btn-outline-secondary btn-sm space"
name="edit-article"
:params="{ slug: article.slug }"
:params="{slug: article.slug}"
>
<i class="ion-edit space" /> Edit Article
</AppLink>
<button
v-if="displayEditButton"
aria-label="Delete article"
class="btn btn-outline-danger btn-sm"
@click="onDelete"
>
@ -59,17 +63,14 @@
</div>
</template>
<script lang="ts" setup>
<script lang="ts">
import { useFavoriteArticle } from 'src/composable/useFavoriteArticle'
import { useFollow } from 'src/composable/useFollowProfile'
import { routerPush } from 'src/router'
import { deleteArticle } from 'src/services/article/deleteArticle'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineEmit, defineProps, toRefs } from 'vue'
import { deleteArticle } from '../services/article/deleteArticle'
import { useFavoriteArticle } from '../composable/useFavoriteArticle'
import { useFollow } from '../composable/useFollowProfile'
import { user, checkAuthorization } from '../store/user'
import { routerPush } from '../router'
const props = defineProps<{
article: Article
}>()
@ -78,7 +79,7 @@ const emit = defineEmit<(e: 'update', article: Article) => void>()
const { article } = toRefs(props)
const displayEditButton = computed(() => checkAuthorization(user) && user.value.username === article.value.author.username)
const displayFollowButton = computed(() => user.value?.username !== article.value.author.username)
const displayFollowButton = computed(() => checkAuthorization(user) && user.value.username !== article.value.author.username)
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited: computed(() => article.value.favorited),

View File

@ -0,0 +1,31 @@
import { flushPromises, mount } from '@vue/test-utils'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesList from 'src/components/ArticlesList.vue'
import { router } from 'src/router'
import { getArticles } from 'src/services/article/getArticles'
import fixtures from 'src/utils/test/fixtures'
jest.mock('src/services/article/getArticles')
const globalMountOptions: GlobalMountOptions = {
plugins: [router],
}
describe('# ArticlesList', () => {
const mockFetchArticles = getArticles as jest.MockedFunction<typeof getArticles>
beforeEach(async () => {
mockFetchArticles.mockResolvedValue({ articles: [fixtures.article], articlesCount: 1 })
await router.push('/')
})
it('should render correctly', async () => {
const wrapper = mount(ArticlesList, {
global: globalMountOptions,
})
await flushPromises()
expect(wrapper).toBeTruthy()
expect(mockFetchArticles).toBeCalledTimes(1)
})
})

View File

@ -1,10 +1,20 @@
<template>
<ArticlesListNavigation v-bind="$attrs" :tag="tag" :username="username" />
<ArticlesListNavigation
v-bind="$attrs"
:tag="tag"
:username="username"
/>
<div v-if="articlesDownloading" class="article-preview">
<div
v-if="articlesDownloading"
class="article-preview"
>
Articles are downloading...
</div>
<div v-else-if="articles.length === 0" class="article-preview">
<div
v-else-if="articles.length === 0"
class="article-preview"
>
No articles are here... yet.
</div>
<template v-else>
@ -12,7 +22,7 @@
v-for="(article, index) in articles"
:key="article.slug"
:article="article"
@update="() => updateArticle(index, $event)"
@update="newArticle => updateArticle(index, newArticle)"
/>
<AppPagination
@ -24,10 +34,10 @@
</template>
<script lang="ts" setup>
import ArticlesListNavigation from './ArticlesListNavigation.vue'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import { useArticles } from 'src/composable/useArticles'
import AppPagination from './AppPagination.vue'
import { useArticles } from '../composable/useArticles'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import ArticlesListNavigation from './ArticlesListNavigation.vue'
const {
fetchArticles,

View File

@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import ArticlesListArticlePreview from 'src/components/ArticlesListArticlePreview.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import fixtures from 'src/utils/test/fixtures'
const mockFavoriteArticle = jest.fn()
jest.mock('src/composable/useFavoriteArticle', () => ({
useFavoriteArticle: () => ({
favoriteProcessGoing: false,
favoriteArticle: mockFavoriteArticle,
}),
}))
describe('# ArticlesListArticlePreview', () => {
const favoriteButton = '[aria-label="Favorite article"]'
beforeEach(async () => {
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
})
it('should call favorite method when click favorite button', async () => {
const wrapper = mount(ArticlesListArticlePreview, {
global: { plugins: [registerGlobalComponents, router] },
props: { article: fixtures.article },
})
await wrapper.find(favoriteButton).trigger('click')
expect(mockFavoriteArticle).toBeCalledTimes(1)
})
})

View File

@ -3,28 +3,27 @@
<div class="article-meta">
<AppLink
name="profile"
:params="{ username: article.author.username }"
:params="{username: article.author.username}"
>
<img :src="article.author.image">
</AppLink>
<div class="info">
<AppLink
name="profile"
:params="{ username: article.author.username }"
:params="{username: article.author.username}"
class="author"
>
{{ article.author.username }}
</AppLink>
<span class="date">{{
new Date(article.createdAt).toDateString()
}}</span>
<span class="date">{{ new Date(article.createdAt).toDateString() }}</span>
</div>
<button
:aria-label="article.favorited ? 'Unfavorite article' : 'Favorite article'"
class="btn btn-sm pull-xs-right"
:class="[article.favorited ? 'btn-primary' : 'btn-outline-primary']"
:class="[article.favorited ? 'btn-primary':'btn-outline-primary']"
:disabled="favoriteProcessGoing"
@click="favoriteArticle"
@click="() =>favoriteArticle()"
>
<i class="ion-heart" /> {{ article.favoritesCount }}
</button>
@ -32,7 +31,7 @@
<AppLink
name="article"
:params="{ slug: article.slug }"
:params="{slug: article.slug}"
class="preview-link"
>
<h1>{{ article.title }}</h1>
@ -52,8 +51,8 @@
</template>
<script lang="ts" setup>
import { useFavoriteArticle } from 'src/composable/useFavoriteArticle'
import { computed, defineEmit, defineProps } from 'vue'
import { useFavoriteArticle } from '../composable/useFavoriteArticle'
const props = defineProps<{
article: Article;

View File

@ -0,0 +1,43 @@
import { mount } from '@vue/test-utils'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesListNavigation from 'src/components/ArticlesListNavigation.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { updateUser, user } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
describe('# ArticlesListNavigation', () => {
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
}
beforeEach(async () => {
updateUser(fixtures.user)
await router.push('/')
})
it('should render global feed item when passed global feed prop', () => {
const wrapper = mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: '', username: '', useGlobalFeed: true },
})
const items = wrapper.findAll('.nav-item')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('Global Feed')
})
it('should render full item', () => {
const wrapper = mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: 'foo', username: '', useGlobalFeed: true, useMyFeed: true, useTagFeed: true },
})
const items = wrapper.findAll('.nav-item')
expect(items).toHaveLength(3)
expect(items[0].text()).toContain('Global Feed')
expect(items[1].text()).toContain('Your Feed')
expect(items[2].text()).toContain('foo')
})
})

View File

@ -23,20 +23,11 @@
</template>
<script lang="ts" setup>
import type { RouteParams } from 'vue-router'
import type { AppRouteNames } from '../router'
import type { ArticlesType } from '../composable/useArticles'
import type { ArticlesType } from 'src/composable/useArticles'
import type { AppRouteNames } from 'src/router'
import { isAuthorized } from 'src/store/user'
import { computed, defineProps } from 'vue'
import { isAuthorized } from '../store/user'
interface ArticlesListNavLink {
name: ArticlesType;
routeName: AppRouteNames;
routeParams?: Partial<RouteParams>;
title: string;
icon?: string;
}
import type { RouteParams } from 'vue-router'
const props = defineProps<{
tag: string;
@ -48,6 +39,14 @@ const props = defineProps<{
useUserFavorited?: boolean;
}>()
interface ArticlesListNavLink {
name: ArticlesType
routeName: AppRouteNames
routeParams?: Partial<RouteParams>
title: string
icon?: string
}
const allLinks = computed<ArticlesListNavLink[]>(() => [
{
name: 'global-feed',
@ -66,7 +65,6 @@ const allLinks = computed<ArticlesListNavLink[]>(() => [
title: props.tag,
icon: 'ion-pound',
},
{
name: 'user-feed',
routeName: 'profile',
@ -89,7 +87,5 @@ const show = computed<Record<ArticlesType, boolean>>(() => ({
'user-favorites-feed': (props.useUserFavorited && props.username !== '') ?? false,
}))
const links = computed<ArticlesListNavLink[]>(() =>
allLinks.value.filter((link) => show.value[link.name]),
)
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
</script>

View File

@ -0,0 +1,32 @@
import { expect } from '@jest/globals'
import { flushPromises, mount } from '@vue/test-utils'
import PopularTags from 'src/components/PopularTags.vue'
import { useTags } from 'src/composable/useTags'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import { ref } from 'vue'
jest.mock('src/composable/useTags')
describe('# PopularTags', () => {
const mockUseTags = useTags as jest.MockedFunction<typeof useTags>
beforeEach(async () => {
const mockFetchTags = jest.fn()
mockUseTags.mockReturnValue({
tags: ref(['foo', 'bar']),
fetchTags: mockFetchTags,
})
await router.push('/')
})
it('should render correctly', async () => {
const wrapper = mount(asyncComponentWrapper(PopularTags), {
global: { plugins: [registerGlobalComponents, router] },
})
await flushPromises()
expect(wrapper.findAll('.tag-pill')).toHaveLength(2)
})
})

View File

@ -6,7 +6,7 @@
v-for="tag in tags"
:key="tag"
name="tag"
:params="{ tag }"
:params="{tag}"
class="tag-pill tag-default"
>
{{ tag }}
@ -15,7 +15,7 @@
</template>
<script lang="ts" setup>
import { useTags } from '../composable/useTags'
import { useTags } from 'src/composable/useTags'
const { tags, fetchTags } = useTags()

View File

@ -1,16 +1,14 @@
import { computed, ComputedRef, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { AppRouteNames } from '../router'
import createAsyncProcess from '../utils/create-async-process'
import type { AppRouteNames } from 'src/router'
import {
getArticles,
getFavoritedArticles,
getProfileArticles,
getFeeds,
getArticlesByTag,
} from '../services/article/getArticles'
getFavoritedArticles,
getFeeds,
getProfileArticles,
} from 'src/services/article/getArticles'
import createAsyncProcess from 'src/utils/create-async-process'
import { computed, ComputedRef, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function useArticles () {

View File

@ -1,12 +1,9 @@
import { routerPush } from 'src/router'
import { deleteFavoriteArticle, postFavoriteArticle } from 'src/services/article/favoriteArticle'
import type { AuthorizationError } from 'src/types/error'
import createAsyncProcess from 'src/utils/create-async-process'
import type { Either } from 'src/utils/either'
import { ComputedRef } from 'vue'
import { routerPush } from '../router'
import type { AuthorizationError } from '../types/error'
import { deleteFavoriteArticle, postFavoriteArticle } from '../services/article/favoriteArticle'
import type { Either } from '../utils/either'
import createAsyncProcess from '../utils/create-async-process'
interface useFavoriteArticleProps {
isFavorited: ComputedRef<boolean>

View File

@ -1,12 +1,9 @@
import { routerPush } from 'src/router'
import { deleteFollowProfile, postFollowProfile } from 'src/services/profile/followProfile'
import type { AuthorizationError } from 'src/types/error'
import createAsyncProcess from 'src/utils/create-async-process'
import type { Either } from 'src/utils/either'
import type { ComputedRef } from 'vue'
import { routerPush } from '../router'
import type { AuthorizationError } from '../types/error'
import type { Either } from '../utils/either'
import createAsyncProcess from '../utils/create-async-process'
import { postFollowProfile, deleteFollowProfile } from '../services/profile/followProfile'
interface UseFollowProps {
username: ComputedRef<string>

View File

@ -1,7 +1,6 @@
import { getProfile } from 'src/services/profile/getProfile'
import { ComputedRef, ref, watch } from 'vue'
import { getProfile } from '../services/profile/getProfile'
interface UseProfileProps {
username: ComputedRef<string>
}

View File

@ -1,7 +1,6 @@
import { getAllTags } from 'src/services/tag/getTags'
import { ref } from 'vue'
import { getAllTags } from '../services/tag/getTags'
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function useTags () {
const tags = ref<string[]>([])

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { router } from 'src/router'
import Article from './Article.vue'
import { router } from '../router'
describe('# Article', () => {
beforeEach(async () => {

View File

@ -29,6 +29,6 @@
</template>
<script lang="ts" setup>
import ArticleDetail from '../components/ArticleDetail.vue'
import ArticleDetailComments from '../components/ArticleDetailComments.vue'
import ArticleDetail from 'src/components/ArticleDetail.vue'
import ArticleDetailComments from 'src/components/ArticleDetailComments.vue'
</script>

View File

@ -65,11 +65,11 @@
</div>
</template>
<script lang="ts" setup>
<script lang="ts">
import { getArticle } from 'src/services/article/getArticle'
import { postArticle, putArticle } from 'src/services/article/postArticle'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getArticle } from '../services/article/getArticle'
import { postArticle, putArticle } from '../services/article/postArticle'
interface FormState {
title: string;

View File

@ -44,6 +44,6 @@
</template>
<script lang="ts" setup>
import ArticlesList from '../components/ArticlesList.vue'
import PopularTags from '../components/PopularTags.vue'
import ArticlesList from 'src/components/ArticlesList.vue'
import PopularTags from 'src/components/PopularTags.vue'
</script>

View File

@ -3,35 +3,48 @@
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Sign in</h1>
<h1 class="text-xs-center">
Sign in
</h1>
<p class="text-xs-center">
<AppLink name="register"> Need an account? </AppLink>
<AppLink name="register">
Need an account?
</AppLink>
</p>
<ul class="error-messages">
<li v-for="(error, field) in errors" :key="field">
{{ field }} {{ error ? error[0] : "" }}
<li
v-for="(error, field) in errors"
:key="field"
>
{{ field }} {{ error ? error[0] : '' }}
</li>
</ul>
<form ref="formRef" @submit.prevent="login">
<fieldset class="form-group" aria-required="true">
<form
ref="formRef"
@submit.prevent="login"
>
<fieldset
class="form-group"
aria-required="true"
>
<input
v-model="form.email"
class="form-control form-control-lg"
type="email"
required
placeholder="Email"
/>
>
</fieldset>
<fieldset class="form-group">
<fieldset class=" form-group">
<input
v-model="form.password"
class="form-control form-control-lg"
type="password"
required
placeholder="Password"
/>
>
</fieldset>
<button
class="btn btn-lg btn-primary pull-xs-right"
@ -48,12 +61,10 @@
</template>
<script lang="ts" setup>
import type { PostLoginForm, PostLoginErrors } from '../services/auth/postLogin'
import { routerPush } from 'src/router'
import { postLogin, PostLoginErrors, PostLoginForm } from 'src/services/auth/postLogin'
import { updateUser } from 'src/store/user'
import { reactive, ref } from 'vue'
import { routerPush } from '../router'
import { postLogin } from '../services/auth/postLogin'
import { updateUser } from '../store/user'
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostLoginForm>({

View File

@ -38,8 +38,7 @@
@click="toggleFollow"
>
<i class="ion-plus-round space" />
{{ profile.following ? "Unfollow" : "Follow" }}
{{ profile.username }}
{{ profile.following ? "Unfollow" : "Follow" }} {{ profile.username }}
</button>
</template>
</div>
@ -68,12 +67,12 @@
</template>
<script lang="ts" setup>
import ArticlesList from 'src/components/ArticlesList.vue'
import { useFollow } from 'src/composable/useFollowProfile'
import { useProfile } from 'src/composable/useProfile'
import { checkAuthorization, user } from 'src/store/user'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import ArticlesList from '../components/ArticlesList.vue'
import { useProfile } from '../composable/useProfile'
import { useFollow } from '../composable/useFollowProfile'
import { user, checkAuthorization } from '../store/user'
const route = useRoute()
const username = computed<string>(() => route.params.username as string)
@ -95,6 +94,6 @@ const showFollow = computed<boolean>(() => user.value?.username !== username.val
margin-right: 4px;
}
.align-left {
text-align: left;
text-align: left
}
</style>

View File

@ -3,18 +3,28 @@
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Sign up</h1>
<h1 class="text-xs-center">
Sign up
</h1>
<p class="text-xs-center">
<AppLink name="login"> Have an account? </AppLink>
<AppLink name="login">
Have an account?
</AppLink>
</p>
<ul class="error-messages">
<li v-for="(error, field) in errors" :key="field">
{{ field }} {{ error ? error[0] : "" }}
<li
v-for="(error, field) in errors"
:key="field"
>
{{ field }} {{ error ? error[0] : '' }}
</li>
</ul>
<form ref="formRef" @submit.prevent="register">
<form
ref="formRef"
@submit.prevent="register"
>
<fieldset class="form-group">
<input
v-model="form.username"
@ -22,7 +32,7 @@
type="text"
required
placeholder="Your Name"
/>
>
</fieldset>
<fieldset class="form-group">
<input
@ -31,7 +41,7 @@
type="email"
required
placeholder="Email"
/>
>
</fieldset>
<fieldset class="form-group">
<input
@ -41,7 +51,7 @@
:minLength="8"
required
placeholder="Password"
/>
>
</fieldset>
<button
type="submit"
@ -58,12 +68,10 @@
</template>
<script lang="ts" setup>
import type { PostRegisterForm, PostRegisterErrors } from '../services/auth/postRegister'
import { routerPush } from 'src/router'
import { postRegister, PostRegisterErrors, PostRegisterForm } from 'src/services/auth/postRegister'
import { updateUser } from 'src/store/user'
import { reactive, ref } from 'vue'
import { routerPush } from '../router'
import { postRegister } from '../services/auth/postRegister'
import { updateUser } from '../store/user'
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostRegisterForm>({

View File

@ -3,7 +3,9 @@
<div class="container page">
<div class="row">
<div class="col-md-6 offset-md-3 col-xs-12">
<h1 class="text-xs-center">Your Settings</h1>
<h1 class="text-xs-center">
Your Settings
</h1>
<form @submit.prevent="onSubmit">
<fieldset>
@ -13,7 +15,7 @@
type="text"
class="form-control"
placeholder="URL of profile picture"
/>
>
</fieldset>
<fieldset class="form-group">
<input
@ -21,7 +23,7 @@
type="text"
class="form-control form-control-lg"
placeholder="Your name"
/>
>
</fieldset>
<fieldset class="form-group">
<textarea
@ -37,7 +39,7 @@
type="email"
class="form-control form-control-lg"
placeholder="Email"
/>
>
</fieldset>
<fieldset class="form-group">
<input
@ -45,7 +47,7 @@
type="password"
class="form-control form-control-lg"
placeholder="New Password"
/>
>
</fieldset>
<button
class="btn btn-lg btn-primary pull-xs-right"
@ -57,9 +59,12 @@
</fieldset>
</form>
<hr />
<hr>
<button class="btn btn-outline-danger" @click="onLogout">
<button
class="btn btn-outline-danger"
@click="onLogout"
>
Or click here to logout.
</button>
</div>
@ -69,12 +74,10 @@
</template>
<script lang="ts" setup>
import type { PutProfileForm } from '../services/profile/putProfile'
import { routerPush } from 'src/router'
import { putProfile, PutProfileForm } from 'src/services/profile/putProfile'
import { checkAuthorization, updateUser, user } from 'src/store/user'
import { computed, onMounted, reactive } from 'vue'
import { routerPush } from '../router'
import { putProfile } from '../services/profile/putProfile'
import { user, checkAuthorization, updateUser } from '../store/user'
const form = reactive<PutProfileForm>({})
@ -101,9 +104,9 @@ onMounted(async () => {
const isButtonDisabled = computed(() => (
form.image === user.value?.image &&
form.username === user.value?.username &&
form.bio === user.value?.bio &&
form.email === user.value?.email &&
!form.password
form.username === user.value?.username &&
form.bio === user.value?.bio &&
form.email === user.value?.email &&
!form.password
))
</script>

View File

@ -1,7 +1,6 @@
import AppLink from 'src/components/AppLink.vue'
import type { App } from 'vue'
import AppLink from '../components/AppLink.vue'
export default function registerGlobalComponents (app: App): void {
app.component('AppLink', AppLink)
}

View File

@ -1,5 +1,5 @@
import { request } from '../services'
import storage from '../utils/storage'
import { request } from 'src/services'
import storage from 'src/utils/storage'
export default function (): void {
const token = storage.get<User>('user')?.token

View File

@ -1,10 +1,8 @@
import type { AuthorizationError } from 'src/types/error'
import { Either, fail, success } from 'src/utils/either'
import { mapAuthorizationResponse } from 'src/utils/map-checkable-response'
import { request } from '../index'
import type { AuthorizationError } from '../../types/error'
import { Either, fail, success } from '../../utils/either'
import { mapAuthorizationResponse } from '../../utils/map-checkable-response'
export async function postFavoriteArticle (slug: string): Promise<Either<AuthorizationError, Article>> {
const result1 = await request.checkablePost<ArticleResponse>(`/articles/${slug}/favorite`)
const result2 = mapAuthorizationResponse<ArticleResponse>(result1)

View File

@ -1,10 +1,8 @@
import type { ValidationError } from 'src/types/error'
import { Either, fail, success } from 'src/utils/either'
import { mapValidationResponse } from 'src/utils/map-checkable-response'
import { request } from '../index'
import type { ValidationError } from '../../types/error'
import { mapValidationResponse } from '../../utils/map-checkable-response'
import { Either, fail, success } from '../../utils/either'
export interface PostLoginForm {
email: string
password: string

View File

@ -1,10 +1,8 @@
import type { ValidationError } from 'src/types/error'
import { Either, fail, success } from 'src/utils/either'
import { mapValidationResponse } from 'src/utils/map-checkable-response'
import { request } from '../index'
import type { ValidationError } from '../../types/error'
import { mapValidationResponse } from '../../utils/map-checkable-response'
import { Either, fail, success } from '../../utils/either'
export interface PostRegisterForm {
email: string
password: string

View File

@ -1,10 +1,8 @@
import type { AuthorizationError } from '../../types/error'
import type { AuthorizationError } from 'src/types/error'
import { Either, fail, success } from 'src/utils/either'
import { mapAuthorizationResponse } from 'src/utils/map-checkable-response'
import { request } from '../index'
import { mapAuthorizationResponse } from '../../utils/map-checkable-response'
import { Either, fail, success } from '../../utils/either'
export async function postFollowProfile (username: string): Promise<Either<AuthorizationError, Profile>> {
const result1 = await request.checkablePost<ProfileResponse>(`/profiles/${username}/follow`)
const result2 = mapAuthorizationResponse<ProfileResponse>(result1)

View File

@ -0,0 +1,14 @@
// https://github.com/vuejs/vue-test-utils-next/issues/258#issuecomment-732249010
import { Component, DefineComponent, defineComponent } from 'vue'
export default function asyncComponentWrapper (component: Component): DefineComponent {
if (component.name === undefined) {
console.log('component name is undefined')
return null as never
}
return defineComponent({
components: { [component.name]: component },
template: `<Suspense><${component.name}/></Suspense>`,
})
}

View File

@ -0,0 +1,50 @@
const author: Profile = {
username: 'Author name',
bio: 'Author bio',
following: false,
image: '',
}
const user: User = {
...author,
id: 1,
email: 'foo@example.com',
token: '',
}
const article: Article = {
slug: 'article-foo',
title: 'Article foo',
author,
tagList: ['foo'],
description: 'Article description',
body: `# Article body
This is **Strong** content.`,
favorited: false,
favoritesCount: 0,
createdAt: '2020-01-01T00:00:00Z',
updatedAt: '2020-01-01T00:00:00Z',
}
const comment: ArticleComment = {
id: 1,
author,
body: 'Comment body',
createdAt: '2020-01-01T00:00:00Z',
updatedAt: '2020-01-01T00:00:00Z',
}
const comment2: ArticleComment = {
...comment,
id: 2,
body: 'comment2',
}
export default {
author,
user,
article,
comment,
comment2,
}

View File

@ -1,5 +1,12 @@
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { defineConfig } from 'vite'
export default {
plugins: [vue()]
}
export default defineConfig({
resolve: {
alias: {
'src': resolve(__dirname, 'src'),
},
},
plugins: [vue()],
})

113
yarn.lock
View File

@ -16,22 +16,22 @@
dependencies:
"@babel/highlight" "^7.12.13"
"@babel/compat-data@^7.13.0":
version "7.13.6"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.6.tgz#11972d07db4c2317afdbf41d6feb3a730301ef4e"
integrity sha512-VhgqKOWYVm7lQXlvbJnWOzwfAQATd2nV52koT0HZ/LdDH0m4DUDwkKYsH+IwpXb+bKPyBJzawA4I6nBKqZcpQw==
"@babel/compat-data@^7.13.8":
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.8.tgz#5b783b9808f15cef71547f1b691f34f8ff6003a6"
integrity sha512-EaI33z19T4qN3xLXsGf48M2cDqa6ei9tPZlfLdb2HC+e/cFtREiRd8hdSqDbwdLB0/+gLwqJmCYASH0z2bUdog==
"@babel/core@^7.1.0", "@babel/core@^7.13.1", "@babel/core@^7.7.5":
version "7.13.1"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.1.tgz#7ddd027176debe40f13bb88bac0c21218c5b1ecf"
integrity sha512-FzeKfFBG2rmFtGiiMdXZPFt/5R5DXubVi82uYhjGX4Msf+pgYQMCFIqFXZWs5vbIYbf14VeBIgdGI03CDOOM1w==
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.8.tgz#c191d9c5871788a591d69ea1dc03e5843a3680fb"
integrity sha512-oYapIySGw1zGhEFRd6lzWNLWFX2s5dA/jm+Pw/+59ZdXtjyIuwlXbrId22Md0rgZVop+aVoqow2riXhBLNyuQg==
dependencies:
"@babel/code-frame" "^7.12.13"
"@babel/generator" "^7.13.0"
"@babel/helper-compilation-targets" "^7.13.0"
"@babel/helper-compilation-targets" "^7.13.8"
"@babel/helper-module-transforms" "^7.13.0"
"@babel/helpers" "^7.13.0"
"@babel/parser" "^7.13.0"
"@babel/parser" "^7.13.4"
"@babel/template" "^7.12.13"
"@babel/traverse" "^7.13.0"
"@babel/types" "^7.13.0"
@ -40,7 +40,7 @@
gensync "^1.0.0-beta.2"
json5 "^2.1.2"
lodash "^4.17.19"
semver "7.0.0"
semver "^6.3.0"
source-map "^0.5.0"
"@babel/generator@^7.13.0":
@ -52,15 +52,15 @@
jsesc "^2.5.1"
source-map "^0.5.0"
"@babel/helper-compilation-targets@^7.13.0":
version "7.13.0"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.0.tgz#c9cf29b82a76fd637f0faa35544c4ace60a155a1"
integrity sha512-SOWD0JK9+MMIhTQiUVd4ng8f3NXhPVQvTv7D3UN4wbp/6cAHnB2EmMaU1zZA2Hh1gwme+THBrVSqTFxHczTh0Q==
"@babel/helper-compilation-targets@^7.13.8":
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.8.tgz#02bdb22783439afb11b2f009814bdd88384bd468"
integrity sha512-pBljUGC1y3xKLn1nrx2eAhurLMA8OqBtBP/JwG4U8skN7kf8/aqwwxpV1N6T0e7r6+7uNitIa/fUxPFagSXp3A==
dependencies:
"@babel/compat-data" "^7.13.0"
"@babel/compat-data" "^7.13.8"
"@babel/helper-validator-option" "^7.12.17"
browserslist "^4.14.5"
semver "7.0.0"
semver "^6.3.0"
"@babel/helper-function-name@^7.12.13":
version "7.12.13"
@ -163,15 +163,15 @@
"@babel/types" "^7.13.0"
"@babel/highlight@^7.10.4", "@babel/highlight@^7.12.13":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.12.13.tgz#8ab538393e00370b26271b01fa08f7f27f2e795c"
integrity sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.8.tgz#10b2dac78526424dfc1f47650d0e415dfd9dc481"
integrity sha512-4vrIhfJyfNf+lCtXC2ck1rKSzDwciqF7IWFhXXrSOUC2O5DrVp+w4c6ed4AllTxhTkUP5x2tYj41VaxdVMMRDw==
dependencies:
"@babel/helper-validator-identifier" "^7.12.11"
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.12.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.0":
"@babel/parser@^7.1.0", "@babel/parser@^7.12.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.0", "@babel/parser@^7.13.4":
version "7.13.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.4.tgz#340211b0da94a351a6f10e63671fa727333d13ab"
integrity sha512-uvoOulWHhI+0+1f9L4BoozY7U5cIkZ9PgJqvb041d6vypgUmtVPG4vmGm4pSggjl8BELzvHyUeJSUyEMY6b+qA==
@ -261,9 +261,9 @@
"@babel/helper-plugin-utils" "^7.12.13"
"@babel/plugin-transform-modules-commonjs@^7.2.0":
version "7.13.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.0.tgz#276932693a20d12c9776093fdc99c0d9995e34c6"
integrity sha512-j7397PkIB4lcn25U2dClK6VLC6pr2s3q+wbE8R3vJvY6U1UTBBj0n6F+5v6+Fd/UwfDPAorMOs2TV+T4M+owpQ==
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.8.tgz#7b01ad7c2dcf2275b06fa1781e00d13d420b3e1b"
integrity sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw==
dependencies:
"@babel/helper-module-transforms" "^7.13.0"
"@babel/helper-plugin-utils" "^7.13.0"
@ -377,9 +377,9 @@
strip-json-comments "^3.1.1"
"@harlem/core@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@harlem/core/-/core-1.1.0.tgz#6826c37e56da6b2f9e921e5a696cf3ee54db0299"
integrity sha512-Zlrm8C3U+wYC7cb5Ewl0QlNp0zNrGzJiyuJ+JKzH/YBkS3CK1SL2Pk88RS51yfczoCkBeGw2w4Dz0f3y3U5wcw==
version "1.1.1"
resolved "https://registry.yarnpkg.com/@harlem/core/-/core-1.1.1.tgz#245a08cb2f68c6aa4e6d70bebe118b0545cf7798"
integrity sha512-v/a4BOXEf+Iw//6pVfY9nXELIKsaevzXKMfw3wj5KP6wQfNlkWjKarMIKrh4wtJchf7OEf50jfZX74SoERTMRg==
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
@ -837,9 +837,9 @@
eslint-visitor-keys "^2.0.0"
"@vitejs/plugin-vue@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-1.1.4.tgz#1dd388519b75439b7733601b55238ca691864796"
integrity sha512-cUDILd++9jdhdjpuhgJofQqOabOKe+kTWTE2HQY2PBHEUO2fgwTurLE0cJg9UcIo1x4lHfsp+59S9TBCHgTZkw==
version "1.1.5"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-1.1.5.tgz#fa1e8e5e049c35e213672e33f73fe81706ad5dbe"
integrity sha512-4DV8VPYo8/OR1YsnK39QN16xhKENt2XvcmJxqfRtyz75kvbjBYh1zTSHLp7XsXqv4R2I+fOZlbEBvxosMYLcPA==
"@vue/compiler-core@3.0.6":
version "3.0.6"
@ -2095,9 +2095,9 @@ ecc-jsbn@~0.1.1:
safer-buffer "^2.1.0"
electron-to-chromium@^1.3.649:
version "1.3.673"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.673.tgz#b4f81c930b388f962b7eba20d0483299aaa40913"
integrity sha512-ms+QR2ckfrrpEAjXweLx6kNCbpAl66DcW//3BZD4BV5KhUgr0RZRce1ON/9J3QyA3JO28nzgb5Xv8DnPr05ILg==
version "1.3.675"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.675.tgz#7ad29f98d7b48da581554eb28bb9a71fd5fd4956"
integrity sha512-GEQw+6dNWjueXGkGfjgm7dAMtXfEqrfDG3uWcZdeaD4cZ3dKYdPRQVruVXQRXtPLtOr5GNVVlNLRMChOZ611pQ==
elegant-spinner@^1.0.1:
version "1.0.1"
@ -2169,10 +2169,10 @@ es-to-primitive@^1.2.1:
is-date-object "^1.0.1"
is-symbol "^1.0.2"
esbuild@^0.8.47:
version "0.8.52"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.8.52.tgz#6dabf11c517af449a96d66da20dfc204ee7b5294"
integrity sha512-b5KzFweLLXoXQwdC/e2+Z80c8uo2M5MgP7yQEEebkFw6In4T9CvYcNoM2ElvJt8ByO04zAZUV0fZkXmXoi2s9A==
esbuild@^0.8.52:
version "0.8.53"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.8.53.tgz#b408bb0ca1b29dab13d8bbf7d59f59afe6776e86"
integrity sha512-GIaYGdMukH58hu+lf07XWAeESBYFAsz8fXnrylHDCbBXKOSNtFmoYA8PhSeSF+3/qzeJ0VjzV9AkLURo5yfu3g==
escalade@^3.1.1:
version "3.1.1"
@ -2938,9 +2938,9 @@ has-flag@^4.0.0:
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
has-symbols@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
has-value@^0.3.1:
version "0.3.1"
@ -4024,9 +4024,9 @@ listr-verbose-renderer@^0.5.0:
figures "^2.0.0"
listr2@^3.2.2:
version "3.3.3"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.3.3.tgz#af44f6a4cb76d17d293aa5cb88ea84b67bc6144e"
integrity sha512-CeQrTeot/OQTrd2loXEBMfwlOjlPeHu/9alA8UyEoiEyncpj/mv2zRLgx32JzO62wbJIBSKgGM2L23XeOwrRlg==
version "3.3.4"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.3.4.tgz#bca480e784877330b9d96d6cdc613ad243332e20"
integrity sha512-b0lhLAvXSr63AtPF9Dgn6tyxm8Kiz6JXpVGM0uZJdnDcZp02jt7FehgAnMfA9R7riQimOKjQgLknBTdz2nmXwQ==
dependencies:
chalk "^4.1.0"
cli-truncate "^2.1.0"
@ -4034,7 +4034,7 @@ listr2@^3.2.2:
indent-string "^4.0.0"
log-update "^4.0.0"
p-map "^4.0.0"
rxjs "^6.6.3"
rxjs "^6.6.6"
through "^2.3.8"
wrap-ansi "^7.0.0"
@ -5182,9 +5182,9 @@ rimraf@^3.0.0, rimraf@^3.0.2:
glob "^7.1.3"
rollup@^2.38.5:
version "2.39.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.39.1.tgz#7afd4cefd8a332c5102a8063d301fde1f31a9173"
integrity sha512-9rfr0Z6j+vE+eayfNVFr1KZ+k+jiUl2+0e4quZafy1x6SFCjzFspfRSO2ZZQeWeX9noeDTUDgg6eCENiEPFvQg==
version "2.40.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.40.0.tgz#efc218eaede7ab590954df50f96195188999c304"
integrity sha512-WiOGAPbXoHu+TOz6hyYUxIksOwsY/21TRWoO593jgYt8mvYafYqQl+axaA8y1z2HFazNUUrsMSjahV2A6/2R9A==
optionalDependencies:
fsevents "~2.3.1"
@ -5200,7 +5200,7 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rxjs@^6.3.3, rxjs@^6.6.3:
rxjs@^6.3.3, rxjs@^6.6.3, rxjs@^6.6.6:
version "6.6.6"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.6.tgz#14d8417aa5a07c5e633995b525e1e3c0dec03b70"
integrity sha512-/oTwee4N4iWzAMAL9xdGKjkEHmIwupR3oXbQjCKywF1BeFohswF3vZdogbmEF6pZkOsXTzWkrZszrWpQTByYVg==
@ -5261,11 +5261,6 @@ semver-compare@^1.0.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
semver@7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@7.x, semver@^7.2.1, semver@^7.3.2:
version "7.3.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
@ -5577,9 +5572,9 @@ string-width@^2.0.0, string-width@^2.1.1:
strip-ansi "^4.0.0"
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
version "4.2.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.1.tgz#1933ce1f470973d224368009bd1316cad81d5f4f"
integrity sha512-LL0OLyN6AnfV9xqGQpDBwedT2Rt63737LxvsRxbcwpa2aIeynBApG2Sm//F3TaLHIR1aJBN52DWklc06b94o5Q==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
@ -6105,11 +6100,11 @@ verror@1.10.0:
extsprintf "^1.2.0"
vite@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.0.3.tgz#ea0329295d4da9341e670036e5e7f0bfa30ae2cf"
integrity sha512-4CUm3FVUHyTSSSK6vHWkj3SVkP+GGNNzwYcFsHOjjc8xQ3BPjJa1JDDmFlYxpxR29ANa+7RWptYPoyHyJ29Nhw==
version "2.0.4"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.0.4.tgz#063532a4139b59a067297d8ebb5960d450907a09"
integrity sha512-+PP89D7AKXFE4gps8c5+4eP5yXTh5qCogjdYX7iSsIxbLZAa26JoGSq6OLk0qdb/fqDh7gtJqGiLbG2V6NvkKQ==
dependencies:
esbuild "^0.8.47"
esbuild "^0.8.52"
postcss "^8.2.1"
resolve "^1.19.0"
rollup "^2.38.5"