Merge branch 'master' into script-setup
This commit is contained in:
commit
9fe0e3770a
|
|
@ -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
|
||||
11
.eslintrc
11
.eslintrc
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -1,5 +1,8 @@
|
|||
# 
|
||||
|
||||

|
||||

|
||||
|
||||
> ### [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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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')"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>([])
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
113
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue