Merge remote-tracking branch 'origin/master' into vite-jest

# Conflicts:
#	src/components/ArticleDetail.spec.ts
#	src/components/ArticleDetailComments.spec.ts
#	src/components/ArticlesList.spec.ts
#	src/components/ArticlesList.vue
This commit is contained in:
mutoe 2021-12-21 00:25:12 +08:00
commit a2440d3dc5
No known key found for this signature in database
GPG Key ID: ABE5E78D073FC208
33 changed files with 538 additions and 659 deletions

View File

@ -20,7 +20,9 @@
"func-call-spacing": "off",
"@typescript-eslint/promise-function-async": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-unused-vars": "off"
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/explicit-function-return-type": "off"
},
"overrides": [
{

View File

@ -2,7 +2,7 @@
### Using the hosted API
Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://conduit.productionready.io/api` and you're good to go!
Simply point your [API requests](https://github.com/gothinkster/realworld/tree/master/api) to `https://api.realworld.io/api` and you're good to go!
### Routing Guidelines
@ -29,7 +29,7 @@ Simply point your [API requests](https://github.com/gothinkster/realworld/tree/m
Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](#header) does this by default):
```html
<link rel="stylesheet" href="//demo.productionready.io/main.css">
<link rel="stylesheet" href="//demo.realworld.io/main.css">
```
Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template).
@ -47,7 +47,7 @@ Alternatively, if you want to make modifications to the theme, check out the [th
- [Settings](#settings)
- [Create/Edit Article](#createedit-article)
- [Article](#article)
## Layout
@ -64,7 +64,7 @@ Alternatively, if you want to make modifications to the theme, check out the [th
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet" type="text/css">
<!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
<link rel="stylesheet" href="//demo.productionready.io/main.css">
<link rel="stylesheet" href="//demo.realworld.io/main.css">
</head>
<body>
@ -256,7 +256,7 @@ Alternatively, if you want to make modifications to the theme, check out the [th
<button class="btn btn-sm btn-outline-secondary action-btn">
<i class="ion-plus-round"></i>
&nbsp;
Follow Eric Simons
Follow Eric Simons
</button>
</div>
@ -486,7 +486,7 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</button>
</div>
</form>
<div class="card">
<div class="card-block">
<p class="card-text">With supporting text below as a natural lead-in to additional content.</p>
@ -518,7 +518,7 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</span>
</div>
</div>
</div>
</div>

View File

@ -1,15 +1,12 @@
# ![RealWorld Example App](logo.png)
[![Codecov branch](https://img.shields.io/codecov/c/github/mutoe/vue3-realworld-example-app/master?logo=codecov&style=for-the-badge)](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
[![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Test/master?label=master&logo=github&style=for-the-badge)](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Amaster)
[![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Test/script-setup?label=script-setup&logo=github&style=for-the-badge)](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Ascript-setup)
[![Codecov branch](https://img.shields.io/codecov/c/github/mutoe/vue3-realworld-example-app/master?label=master&logo=codecov&style=flat-square)](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
[![Codecov branch](https://img.shields.io/codecov/c/github/mutoe/vue3-realworld-example-app/script-setup?label=script-setup&logo=codecov&style=flat-square)](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/script-setup)
[![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Test/script-setup?label=ref-sugar&logo=github&style=for-the-badge)](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Aref-sugar)
> ### [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.
- [Demo (master branch)](https://vue3-realworld-example-app-mutoe.vercel.app)
- [Demo (script-setup branch)](https://vue3-realworld-example-app-setup.vercel.app)
- [Demo](https://vue3-realworld-example-app-mutoe.vercel.app)
- [RealWorld](https://github.com/gothinkster/realworld)
@ -23,22 +20,19 @@ For more information on how to this works with other frontends/backends, head ov
- [x] [Vite](https://github.com/vitejs/vite)
- [x] [Composition API](https://composition-api.vuejs.org/)
- [x] [SFC \<script setup> sugar](https://v3.vuejs.org/api/sfc-script-setup.html)
- [x] [Suspense](https://v3.vuejs.org/guide/component-dynamic-async.html#using-with-suspense) (Experimental)
- [x] [TypeScript](https://www.typescriptlang.org/)
- [x] [ESLint](https://eslint.vuejs.org/)
- [x] [Vue router](https://next.router.vuejs.org/)
- [x] [Harlem](https://github.com/andrewcourtice/harlem) ([await Vuex v5](https://github.com/mutoe/vue3-realworld-example-app/issues/15))
- [x] Unit test ([Vue Test Utils](https://github.com/vuejs/vue-test-utils-next))
- [ ] Unit test ([Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro))
- [x] State management ([Harlem](https://github.com/andrewcourtice/harlem) ([await Vuex v5](https://github.com/mutoe/vue3-realworld-example-app/issues/15)))
- [x] Type system [TypeScript](https://www.typescriptlang.org/) [Vue tsc](https://github.com/johnsoncodehk/vue-tsc)
- [x] Linter [ESLint](https://eslint.vuejs.org/)
- [x] Unit test ([Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro))
- [x] E2E test ([Cypress](https://docs.cypress.io))
- [x] [Vue tsc](https://github.com/johnsoncodehk/vue-tsc)
#### What works in [script-setup branch](https://github.com/mutoe/vue3-realworld-example-app/tree/script-setup) (based on the master branch)
#### What works in [ref-sugar branch](https://github.com/mutoe/vue3-realworld-example-app/tree/ref-sugar) (based on the master branch)
- [x] [SFC script setup](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup)
- [x] [Script ref sugar (take 2)](https://github.com/vuejs/rfcs/discussions/369)
- ~~[x] Unit test ([Vue Testing Library](https://testing-library.com/docs/vue-testing-library/intro))~~
- [ ] [Cypress component test](https://docs.cypress.io/guides/component-testing/introduction#What-is-Component-Testing)
- [ ] Unit test [Cypress component test](https://docs.cypress.io/guides/component-testing/introduction#What-is-Component-Testing)
> _[Why we have the second branch?](https://github.com/mutoe/vue3-realworld-example-app/commit/c0c983dba08cb31fc96bbc3eb7f15faf469d0624#commitcomment-47600736)_

View File

@ -10,7 +10,7 @@
"author": {
"username": "plumrx",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": true,

View File

@ -11,7 +11,7 @@
"author": {
"username": "maili",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -66,7 +66,7 @@
"author": {
"username": "josemmg87",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -83,7 +83,7 @@
"author": {
"username": "josemmg87",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -100,7 +100,7 @@
"author": {
"username": "Bettie72",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -119,7 +119,7 @@
"author": {
"username": "vasiliy12",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -136,7 +136,7 @@
"author": {
"username": "josemmg87",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -153,7 +153,7 @@
"author": {
"username": "josemmg87",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -170,7 +170,7 @@
"author": {
"username": "Antonette74",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,

View File

@ -15,7 +15,7 @@
"author": {
"username": "zzzazzzzzzz",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -34,7 +34,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -75,7 +75,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -95,7 +95,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -115,7 +115,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -135,7 +135,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -155,7 +155,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,
@ -175,7 +175,7 @@
"author": {
"username": "sagsagsag",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
},
"favorited": false,

View File

@ -2,7 +2,7 @@
"profile": {
"username": "ma0dong",
"bio": null,
"image": "https://static.productionready.io/images/smiley-cyrus.jpg",
"image": "https://static.realworld.io/images/smiley-cyrus.jpg",
"following": false
}
}

View File

@ -4,16 +4,7 @@
<AppFooter />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
<script setup lang="ts">
import AppFooter from './components/AppFooter.vue'
import AppNavigation from './components/AppNavigation.vue'
export default defineComponent({
name: 'App',
components: {
AppNavigation,
AppFooter,
},
})
</script>

View File

@ -21,11 +21,3 @@
</div>
</footer>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AppFooter',
})
</script>

View File

@ -8,27 +8,20 @@
</router-link>
</template>
<script lang="ts">
<script setup lang="ts">
import type { AppRouteNames } from 'src/router'
import { defineComponent, PropType } from 'vue'
import { useAttrs } from 'vue'
import type { RouteParams } from 'vue-router'
import { RouterLink } from 'vue-router'
export default defineComponent({
name: 'AppLink',
components: {
RouterLink,
},
props: {
name: { type: String as PropType<AppRouteNames>, required: true },
params: { type: Object as PropType<RouteParams>, default: () => ({}) },
},
setup (props, { attrs }) {
return {
props,
attrs,
}
},
interface Props {
name: AppRouteNames,
params?: RouteParams
}
const props = withDefaults(defineProps<Props>(), {
params: () => ({}),
})
const attrs = useAttrs()
</script>

View File

@ -23,7 +23,8 @@
<i
v-if="link.icon"
:class="link.icon"
/> {{ link.title }}
/>
{{ link.title }}
</AppLink>
</li>
</ul>
@ -31,10 +32,10 @@
</nav>
</template>
<script lang="ts">
<script setup lang="ts">
import type { AppRouteNames } from 'src/router'
import { user } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import { computed } from 'vue'
import type { RouteParams } from 'vue-router'
interface NavLink {
@ -45,55 +46,47 @@ interface NavLink {
display: 'all' | 'anonym' | 'authorized'
}
export default defineComponent({
name: 'AppNavigation',
setup () {
const username = computed(() => user.value?.username)
const displayStatus = computed(() => username.value ? 'authorized' : 'anonym')
const username = computed(() => user.value?.username)
const displayStatus = computed(() => username.value ? 'authorized' : 'anonym')
const allNavLinks = computed<NavLink[]>(() => [
{
name: 'global-feed',
title: 'Home',
display: 'all',
},
{
name: 'login',
title: 'Sign in',
display: 'anonym',
},
{
name: 'register',
title: 'Sign up',
display: 'anonym',
},
{
name: 'create-article',
title: 'New Post',
display: 'authorized',
icon: 'ion-compose',
},
{
name: 'settings',
title: 'Settings',
display: 'authorized',
icon: 'ion-gear-a',
},
{
name: 'profile',
params: { username: username.value },
title: username.value || '',
display: 'authorized',
},
])
const navLinks = computed(() => allNavLinks.value.filter(
l => l.display === displayStatus.value || l.display === 'all',
))
return {
navLinks,
}
const allNavLinks = computed<NavLink[]>(() => [
{
name: 'global-feed',
title: 'Home',
display: 'all',
},
})
{
name: 'login',
title: 'Sign in',
display: 'anonym',
},
{
name: 'register',
title: 'Sign up',
display: 'anonym',
},
{
name: 'create-article',
title: 'New Post',
display: 'authorized',
icon: 'ion-compose',
},
{
name: 'settings',
title: 'Settings',
display: 'authorized',
icon: 'ion-gear-a',
},
{
name: 'profile',
params: { username: username.value },
title: username.value || '',
display: 'authorized',
},
])
const navLinks = computed(() => allNavLinks.value.filter(
l => l.display === displayStatus.value || l.display === 'all',
))
</script>

View File

@ -15,31 +15,24 @@
</ul>
</template>
<script lang="ts">
<script setup lang="ts">
import { limit } from 'src/services'
import { computed, defineComponent, toRefs } from 'vue'
import { computed, toRefs } from 'vue'
export default defineComponent({
name: 'AppPagination',
props: {
page: { type: Number, required: true },
count: { type: Number, required: true },
},
emits: {
'page-change': (index: number) => typeof index === 'number',
},
setup (props, { emit }) {
const { count, page } = toRefs(props)
const pagesCount = computed(() => Math.ceil(count.value / limit))
const isActive = (index: number) => page.value === index
const onPageChange = (index: number) => emit('page-change', index)
interface Props {
page: number
count: number
}
return {
pagesCount,
isActive,
onPageChange,
}
},
})
interface Emits {
(e: 'page-change', index: number):void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { count, page } = toRefs(props)
const pagesCount = computed(() => Math.ceil(count.value / limit))
const isActive = (index: number) => page.value === index
const onPageChange = (index: number) => emit('page-change', index)
</script>

View File

@ -1,11 +1,10 @@
import { jest } from '@jest/globals'
import { render } from '@testing-library/vue'
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 { renderAsync } from '../utils/test/render-async'
import ArticleDetail from './ArticleDetail.vue'
import fixtures from 'src/utils/test/fixtures'
jest.mock('src/services/article/getArticle')
@ -21,7 +20,7 @@ describe.skip('# ArticleDetail', () => {
it('should render markdown body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdown })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
const { container } = await renderAsync(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
@ -30,7 +29,7 @@ describe.skip('# ArticleDetail', () => {
it('should render markdown (zh-CN) body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownCN })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
const { container } = await renderAsync(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
@ -39,7 +38,7 @@ describe.skip('# ArticleDetail', () => {
it('should filter the xss content in Markdown body', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownXss })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
const { container } = await renderAsync(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})

View File

@ -42,35 +42,20 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import marked from 'src/plugins/marked'
import { getArticle } from 'src/services/article/getArticle'
import { computed, defineComponent, reactive } from 'vue'
import { computed, reactive } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
export default defineComponent({
name: 'ArticleDetail',
components: {
ArticleDetailMeta,
},
async setup () {
const route = useRoute()
const slug = route.params.slug as string
const article = reactive<Article>(await getArticle(slug))
const route = useRoute()
const slug = route.params.slug as string
const article = reactive<Article>(await getArticle(slug))
const articleHandledBody = computed(() => marked(article.body))
const articleHandledBody = computed(() => marked(article.body))
const updateArticle = (newArticle: Article) => {
Object.assign(article, newArticle)
}
return {
article,
articleHandledBody,
slug,
updateArticle,
}
},
})
const updateArticle = (newArticle: Article) => {
Object.assign(article, newArticle)
}
</script>

View File

@ -36,31 +36,29 @@
role="button"
aria-label="Delete comment"
class="ion-trash-a"
@click="$emit('remove-comment')"
@click="emit('remove-comment')"
/>
</span>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue'
<script setup lang="ts">
import { computed } from 'vue'
export default defineComponent({
name: 'ArticleDetailComment',
props: {
comment: { type: Object as PropType<ArticleComment>, required: true },
username: { type: String as PropType<string | undefined>, default: undefined },
},
emits: {
'remove-comment': () => true,
},
setup (props) {
return {
showRemove: computed(() => (
props.username !== undefined && props.username === props.comment.author.username
)),
}
},
})
interface Props {
comment: ArticleComment
username?: string
}
interface Emits {
(e: 'remove-comment'): boolean
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const showRemove = computed(() => (
props.username !== undefined && props.username === props.comment.author.username
))
</script>

View File

@ -1,9 +1,9 @@
import { jest } from '@jest/globals'
import { render, waitFor } from '@testing-library/vue'
import { waitFor } from '@testing-library/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import { renderAsync } from '../utils/test/render-async'
jest.unstable_mockModule('src/services/comment/getComments', () => ({
getCommentsByArticle: jest.fn(),
@ -31,7 +31,7 @@ describe('# ArticleDetailComments', () => {
})
it('should render correctly', async () => {
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
const { container } = await renderAsync(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
@ -41,7 +41,7 @@ describe('# ArticleDetailComments', () => {
it.skip('should display new comment when post new comment', async () => {
// given
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
const { container } = await renderAsync(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
@ -58,7 +58,7 @@ describe('# ArticleDetailComments', () => {
it.skip('should call remove comment service when click delete button', async () => {
// given
render(asyncComponentWrapper(ArticleDetailComments), {
await renderAsync(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
await waitFor(() => expect(mockGetCommentsByArticle).toBeCalled())

View File

@ -13,47 +13,31 @@
/>
</template>
<script lang="ts">
<script setup lang="ts">
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import { user } from 'src/store/user'
import { computed, defineComponent, ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailComment from './ArticleDetailComment.vue'
import ArticleDetailCommentsForm from './ArticleDetailCommentsForm.vue'
export default defineComponent({
name: 'ArticleDetailComments',
components: {
ArticleDetailCommentsForm,
ArticleDetailComment,
},
async setup () {
const route = useRoute()
const slug = route.params.slug as string
const route = useRoute()
const slug = route.params.slug as string
const username = computed(() => user.value?.username)
const username = computed(() => user.value?.username)
const comments = ref<ArticleComment[]>([])
const comments = ref<ArticleComment[]>([])
const addComment = async (comment: ArticleComment) => {
comments.value.unshift(comment)
}
const addComment = async (comment: ArticleComment) => {
comments.value.unshift(comment)
}
const removeComment = async (commentId: number) => {
await deleteComment(slug, commentId)
comments.value = comments.value.filter(c => c.id !== commentId)
}
const removeComment = async (commentId: number) => {
await deleteComment(slug, commentId)
comments.value = comments.value.filter(c => c.id !== commentId)
}
comments.value = await getCommentsByArticle(slug)
comments.value = await getCommentsByArticle(slug)
return {
comments,
slug,
username,
addComment,
removeComment,
}
},
})
</script>

View File

@ -37,37 +37,31 @@
</form>
</template>
<script lang="ts">
<script setup lang="ts">
import { useProfile } from 'src/composable/useProfile'
import { postComment } from 'src/services/comment/postComment'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineComponent, ref } from 'vue'
import { computed, ref } from 'vue'
export default defineComponent({
name: 'ArticleDetailCommentsForm',
props: {
articleSlug: { type: String, required: true },
},
emits: {
'add-comment': (comment: ArticleComment) => !!comment.id,
},
setup (props, { emit }) {
const username = computed(() => checkAuthorization(user) ? user.value.username : '')
const { profile } = useProfile({ username })
interface Props {
articleSlug: string
}
interface Emits {
(e: 'add-comment', comment: ArticleComment): void
}
const comment = ref('')
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const submitComment = async () => {
const newComment = await postComment(props.articleSlug, comment.value)
emit('add-comment', newComment)
comment.value = ''
}
const username = computed(() => checkAuthorization(user) ? user.value.username : '')
const { profile } = useProfile({ username })
const comment = ref('')
const submitComment = async () => {
const newComment = await postComment(props.articleSlug, comment.value)
emit('add-comment', newComment)
comment.value = ''
}
return {
profile,
comment,
submitComment,
}
},
})
</script>

View File

@ -63,56 +63,45 @@
</div>
</template>
<script lang="ts">
<script setup 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, defineComponent, PropType, toRefs } from 'vue'
import { computed, toRefs } from 'vue'
export default defineComponent({
name: 'ArticleDetailMeta',
props: {
article: { type: Object as PropType<Article>, required: true },
},
emits: {
update: (article: Article) => !!article.slug,
},
setup (props, { emit }) {
const { article } = toRefs(props)
const displayEditButton = computed(() => checkAuthorization(user) && user.value.username === article.value.author.username)
const displayFollowButton = computed(() => checkAuthorization(user) && user.value.username !== article.value.author.username)
interface Props {
article: Article,
}
interface Emits {
(e: 'update', article: Article): void
}
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited: computed(() => article.value.favorited),
articleSlug: computed(() => article.value.slug),
onUpdate: newArticle => emit('update', newArticle),
})
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const onDelete = async () => {
await deleteArticle(article.value.slug)
await routerPush('global-feed')
}
const { article } = toRefs(props)
const displayEditButton = computed(() => checkAuthorization(user) && user.value.username === article.value.author.username)
const displayFollowButton = computed(() => checkAuthorization(user) && user.value.username !== article.value.author.username)
const { followProcessGoing, toggleFollow } = useFollow({
following: computed(() => article.value.author.following),
username: computed(() => article.value.author.username),
onUpdate: (author: Profile) => {
const newArticle = { ...article.value, author }
emit('update', newArticle)
},
})
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited: computed(() => article.value.favorited),
articleSlug: computed(() => article.value.slug),
onUpdate: newArticle => emit('update', newArticle),
})
return {
displayEditButton,
displayFollowButton,
onDelete,
favoriteProcessGoing,
favoriteArticle,
followProcessGoing,
toggleFollow,
}
const onDelete = async () => {
await deleteArticle(article.value.slug)
await routerPush('global-feed')
}
const { followProcessGoing, toggleFollow } = useFollow({
following: computed(() => article.value.author.following),
username: computed(() => article.value.author.username),
onUpdate: (author: Profile) => {
const newArticle = { ...article.value, author }
emit('update', newArticle)
},
})
</script>

View File

@ -1,9 +1,9 @@
import { jest } from '@jest/globals'
import { render } from '@testing-library/vue'
import { waitFor } from '@testing-library/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import registerGlobalComponents from 'src/plugins/global-components'
import fixtures from 'src/utils/test/fixtures'
import asyncComponentWrapper from '../utils/test/async-component-wrapper'
import { renderAsync } from '../utils/test/render-async'
jest.unstable_mockModule('src/services/article/getArticles', () => ({
getArticles: jest.fn(),
@ -31,11 +31,11 @@ describe('# ArticlesList', () => {
})
it('should render correctly', async () => {
const wrapper = render(asyncComponentWrapper(ArticlesList), {
const wrapper = renderAsync(ArticlesList, {
global: globalMountOptions,
})
expect(wrapper).toBeTruthy()
expect(mockFetchArticles).toBeCalledTimes(1)
await waitFor(() => expect(mockFetchArticles).toBeCalledTimes(1))
})
})

View File

@ -33,46 +33,24 @@
</template>
</template>
<script lang="ts">
<script setup lang="ts">
import { useArticles } from 'src/composable/useArticles'
import { defineComponent } from 'vue'
import AppPagination from './AppPagination.vue'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import ArticlesListNavigation from './ArticlesListNavigation.vue'
export default defineComponent({
name: 'ArticlesList',
components: {
AppPagination,
ArticlesListArticlePreview,
ArticlesListNavigation,
},
const {
fetchArticles,
articlesDownloading,
articlesCount,
articles,
updateArticle,
page,
changePage,
tag,
username,
} = useArticles()
async setup () {
const {
fetchArticles,
articlesDownloading,
articlesCount,
articles,
updateArticle,
page,
changePage,
tag,
username,
} = useArticles()
await fetchArticles()
await fetchArticles()
return {
articlesDownloading,
articles,
articlesCount,
page,
changePage,
updateArticle,
tag,
username,
}
},
})
</script>

View File

@ -50,35 +50,27 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useFavoriteArticle } from 'src/composable/useFavoriteArticle'
import { computed, defineComponent, PropType } from 'vue'
import { computed } from 'vue'
export default defineComponent({
name: 'ArticlesListArticlePreview',
props: {
article: {
type: Object as PropType<Article>,
required: true,
},
},
emits: {
update: (article: Article) => !!article.slug,
},
setup (props, { emit }) {
const {
favoriteProcessGoing,
favoriteArticle,
} = useFavoriteArticle({
isFavorited: computed(() => props.article.favorited),
articleSlug: computed(() => props.article.slug),
onUpdate: (newArticle: Article): void => emit('update', newArticle),
})
interface Props {
article: Article
}
interface Emits {
(e: 'update', article: Article): void
}
return {
favoriteProcessGoing,
favoriteArticle,
}
},
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const {
favoriteProcessGoing,
favoriteArticle,
} = useFavoriteArticle({
isFavorited: computed(() => props.article.favorited),
articleSlug: computed(() => props.article.slug),
onUpdate: (newArticle: Article): void => emit('update', newArticle),
})
</script>

View File

@ -22,11 +22,11 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ArticlesType } from 'src/composable/useArticles'
import type { AppRouteNames } from 'src/router'
import { isAuthorized } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import { computed } from 'vue'
import type { RouteParams } from 'vue-router'
interface ArticlesListNavLink {
@ -37,63 +37,64 @@ interface ArticlesListNavLink {
icon?: string
}
export default defineComponent({
name: 'ArticlesListNavigation',
props: {
useGlobalFeed: { type: Boolean, default: false },
useMyFeed: { type: Boolean, default: false },
useTagFeed: { type: Boolean, default: false },
useUserFeed: { type: Boolean, default: false },
useUserFavorited: { type: Boolean, default: false },
tag: { type: String, required: true },
username: { type: String, required: true },
},
setup (props) {
const allLinks = computed<ArticlesListNavLink[]>(() => [
{
name: 'global-feed',
routeName: 'global-feed',
title: 'Global Feed',
},
{
name: 'my-feed',
routeName: 'my-feed',
title: 'Your Feed',
},
{
name: 'tag-feed',
routeName: 'tag',
routeParams: { tag: props.tag },
title: props.tag,
icon: 'ion-pound',
},
{
name: 'user-feed',
routeName: 'profile',
routeParams: { username: props.username },
title: 'My articles',
},
{
name: 'user-favorites-feed',
routeName: 'profile-favorites',
routeParams: { username: props.username },
title: 'Favorited Articles',
},
])
interface Props {
useGlobalFeed?: boolean
useMyFeed?: boolean
useTagFeed?: boolean
useUserFeed?: boolean
useUserFavorited?: boolean
tag: string
username: string
}
const show = computed<Record<ArticlesType, boolean>>(() => ({
'global-feed': props.useGlobalFeed,
'my-feed': props.useMyFeed && isAuthorized.value,
'tag-feed': props.useTagFeed && props.tag !== '',
'user-feed': props.useUserFeed && props.username !== '',
'user-favorites-feed': props.useUserFavorited && props.username !== '',
}))
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
return {
links,
}
},
const props = withDefaults(defineProps<Props>(), {
useGlobalFeed: false,
useMyFeed: false,
useTagFeed: false,
useUserFavorited: false,
useUserFeed: false,
})
const allLinks = computed<ArticlesListNavLink[]>(() => [
{
name: 'global-feed',
routeName: 'global-feed',
title: 'Global Feed',
},
{
name: 'my-feed',
routeName: 'my-feed',
title: 'Your Feed',
},
{
name: 'tag-feed',
routeName: 'tag',
routeParams: { tag: props.tag },
title: props.tag,
icon: 'ion-pound',
},
{
name: 'user-feed',
routeName: 'profile',
routeParams: { username: props.username },
title: 'My articles',
},
{
name: 'user-favorites-feed',
routeName: 'profile-favorites',
routeParams: { username: props.username },
title: 'Favorited Articles',
},
])
const show = computed<Record<ArticlesType, boolean>>(() => ({
'global-feed': props.useGlobalFeed,
'my-feed': props.useMyFeed && isAuthorized.value,
'tag-feed': props.useTagFeed && props.tag !== '',
'user-feed': props.useUserFeed && props.username !== '',
'user-favorites-feed': props.useUserFavorited && props.username !== '',
}))
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
</script>

View File

@ -14,20 +14,9 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
<script setup lang="ts">
import { useTags } from 'src/composable/useTags'
const { tags, fetchTags } = useTags()
export default defineComponent({
name: 'PopularTags',
async setup () {
const { tags, fetchTags } = useTags()
await fetchTags()
return {
tags,
}
},
})
await fetchTags()
</script>

View File

@ -28,16 +28,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import ArticleDetail from 'src/components/ArticleDetail.vue'
import ArticleDetailComments from 'src/components/ArticleDetailComments.vue'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ArticlePage',
components: {
ArticleDetail,
ArticleDetailComments,
},
})
</script>

View File

@ -65,10 +65,10 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { getArticle } from 'src/services/article/getArticle'
import { postArticle, putArticle } from 'src/services/article/postArticle'
import { computed, defineComponent, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
interface FormState {
@ -78,60 +78,48 @@ interface FormState {
tagList: string[];
}
export default defineComponent({
name: 'EditArticlePage',
setup () {
const route = useRoute()
const router = useRouter()
const slug = computed<string>(() => route.params.slug as string)
const route = useRoute()
const router = useRouter()
const slug = computed<string>(() => route.params.slug as string)
const form = reactive<FormState>({
title: '',
description: '',
body: '',
tagList: [],
})
const newTag = ref<string>('')
const addTag = () => {
form.tagList.push(newTag.value.trim())
newTag.value = ''
}
const removeTag = (tag: string) => {
form.tagList = form.tagList.filter(t => t !== tag)
}
async function fetchArticle (slug: string) {
const article = await getArticle(slug)
// FIXME: I always feel a little wordy here
form.title = article.title
form.description = article.description
form.body = article.body
form.tagList = article.tagList
}
onMounted(() => {
if (slug.value) fetchArticle(slug.value)
})
const onSubmit = async () => {
let article: Article
if (slug.value) {
article = await putArticle(slug.value, form)
} else {
article = await postArticle(form)
}
return router.push({ name: 'article', params: { slug: article.slug } })
}
return {
form,
onSubmit,
newTag,
addTag,
removeTag,
}
},
const form = reactive<FormState>({
title: '',
description: '',
body: '',
tagList: [],
})
const newTag = ref<string>('')
const addTag = () => {
form.tagList.push(newTag.value.trim())
newTag.value = ''
}
const removeTag = (tag: string) => {
form.tagList = form.tagList.filter(t => t !== tag)
}
async function fetchArticle (slug: string) {
const article = await getArticle(slug)
// FIXME: I always feel a little wordy here
form.title = article.title
form.description = article.description
form.body = article.body
form.tagList = article.tagList
}
onMounted(() => {
if (slug.value) fetchArticle(slug.value)
})
const onSubmit = async () => {
let article: Article
if (slug.value) {
article = await putArticle(slug.value, form)
} else {
article = await postArticle(form)
}
return router.push({ name: 'article', params: { slug: article.slug } })
}
</script>

View File

@ -43,16 +43,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import ArticlesList from 'src/components/ArticlesList.vue'
import PopularTags from 'src/components/PopularTags.vue'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'HomePage',
components: {
ArticlesList,
PopularTags,
},
})
</script>

View File

@ -60,41 +60,30 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { routerPush } from 'src/router'
import { postLogin, PostLoginErrors, PostLoginForm } from 'src/services/auth/postLogin'
import { updateUser } from 'src/store/user'
import { defineComponent, reactive, ref } from 'vue'
import { reactive, ref } from 'vue'
export default defineComponent({
name: 'LoginPage',
setup () {
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostLoginForm>({
email: '',
password: '',
})
const errors = ref<PostLoginErrors>({})
const login = async () => {
if (!formRef.value?.checkValidity()) return
const result = await postLogin(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
}
}
return {
formRef,
form,
login,
errors,
}
},
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostLoginForm>({
email: '',
password: '',
})
const errors = ref<PostLoginErrors>({})
const login = async () => {
if (!formRef.value?.checkValidity()) return
const result = await postLogin(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
}
}
</script>

View File

@ -66,44 +66,28 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
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, defineComponent } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
name: 'ProfilePage',
components: {
ArticlesList,
},
setup () {
const route = useRoute()
const username = computed<string>(() => route.params.username as string)
const route = useRoute()
const username = computed<string>(() => route.params.username as string)
const { profile, updateProfile } = useProfile({ username })
const { profile, updateProfile } = useProfile({ username })
const { followProcessGoing, toggleFollow } = useFollow({
following: computed<boolean>(() => profile.value?.following ?? false),
username,
onUpdate: (newProfileData: Profile) => updateProfile(newProfileData),
})
const showEdit = computed<boolean>(() => checkAuthorization(user) && user.value.username === username.value)
const showFollow = computed<boolean>(() => user.value?.username !== username.value)
return {
profile,
showEdit,
showFollow,
followProcessGoing,
toggleFollow,
}
},
const { followProcessGoing, toggleFollow } = useFollow({
following: computed<boolean>(() => profile.value?.following ?? false),
username,
onUpdate: (newProfileData: Profile) => updateProfile(newProfileData),
})
const showEdit = computed<boolean>(() => checkAuthorization(user) && user.value.username === username.value)
const showFollow = computed<boolean>(() => user.value?.username !== username.value)
</script>
<style scoped>

View File

@ -67,42 +67,30 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { routerPush } from 'src/router'
import { postRegister, PostRegisterErrors, PostRegisterForm } from 'src/services/auth/postRegister'
import { updateUser } from 'src/store/user'
import { defineComponent, reactive, ref } from 'vue'
import { reactive, ref } from 'vue'
export default defineComponent({
name: 'RegisterPage',
setup () {
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostRegisterForm>({
username: '',
email: '',
password: '',
})
const errors = ref<PostRegisterErrors>({})
const register = async () => {
if (!formRef.value?.checkValidity()) return
const result = await postRegister(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
}
}
return {
formRef,
form,
register,
errors,
}
},
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostRegisterForm>({
username: '',
email: '',
password: '',
})
const errors = ref<PostRegisterErrors>({})
const register = async () => {
if (!formRef.value?.checkValidity()) return
const result = await postRegister(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
}
}
</script>

View File

@ -73,52 +73,41 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { routerPush } from 'src/router'
import { putProfile, PutProfileForm } from 'src/services/profile/putProfile'
import { checkAuthorization, updateUser, user } from 'src/store/user'
import { computed, defineComponent, onMounted, reactive } from 'vue'
import { computed, onMounted, reactive } from 'vue'
export default defineComponent({
name: 'SettingsPage',
setup () {
const form = reactive<PutProfileForm>({})
const form = reactive<PutProfileForm>({})
const onSubmit = async () => {
const filteredForm = Object.entries(form).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {})
const userData = await putProfile(filteredForm)
updateUser(userData)
await routerPush('profile', { username: userData.username })
}
const onSubmit = async () => {
const filteredForm = Object.entries(form).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {})
const userData = await putProfile(filteredForm)
updateUser(userData)
await routerPush('profile', { username: userData.username })
}
const onLogout = async () => {
updateUser(null)
await routerPush('global-feed')
}
const onLogout = async () => {
updateUser(null)
await routerPush('global-feed')
}
onMounted(async () => {
if (!checkAuthorization(user)) return await routerPush('login')
onMounted(async () => {
if (!checkAuthorization(user)) return await routerPush('login')
form.image = user.value.image
form.username = user.value.username
form.bio = user.value.bio
form.email = user.value.email
})
form.image = user.value.image
form.username = user.value.username
form.bio = user.value.bio
form.email = user.value.email
})
const isButtonDisabled = computed(() => (
form.image === user.value?.image &&
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
))
))
return {
form,
onSubmit,
isButtonDisabled,
onLogout,
}
},
})
</script>

View File

@ -1,14 +0,0 @@
// 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,106 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* WARNING 01-12-2021: Vue testing library doesn't support <Suspense> see:
* https://github.com/testing-library/vue-testing-library/issues/230
*
* The code below is copied from vue testing library
* and modified to support <Suspense>:
* https://github.com/testing-library/vue-testing-library/blob/main/src/render.js
*/
import { mount, flushPromises, VueWrapper } from '@vue/test-utils'
import { h, defineComponent, Suspense } from 'vue'
import { getQueriesForElement, prettyDOM, RenderOptions, RenderResult } from '@testing-library/vue'
const mountedWrappers = new Set<VueWrapper<any>>()
async function renderAsync (
Component: any,
{
store = null,
routes = null,
container: customContainer,
baseElement: customBaseElement,
...mountOptions
}: RenderOptions = {},
): Promise<RenderResult> {
const div = document.createElement('div')
const baseElement = customBaseElement || customContainer || document.body
const container = customContainer || baseElement.appendChild(div)
if (store || routes) {
console.warn(`Providing 'store' or 'routes' options is no longer available.
You need to create a router/vuex instance and provide it through 'global.plugins'.
Check out the test examples on GitHub for further details.`)
}
const { props, slots, ...restMountingOptions } = mountOptions
const wrapper = mount(
defineComponent({
render () {
return h(Suspense, null, {
default: h(Component, props, slots),
})
},
}),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
{
attachTo: container,
...restMountingOptions,
} as RenderOptions,
)
await flushPromises()
// this removes the additional "data-v-app" div node from VTU:
// https://github.com/vuejs/vue-test-utils-next/blob/master/src/mount.ts#L196-L213
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
unwrapNode(wrapper.parentElement)
mountedWrappers.add(wrapper)
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return {
container,
baseElement,
debug: (el = baseElement, maxLength, options: any) =>
Array.isArray(el)
? el.forEach((e) =>
console.log(prettyDOM(e as Element, maxLength, options)),
)
: console.log(prettyDOM(el as Element, maxLength, options)),
unmount: () => wrapper.unmount(),
html: () => wrapper.html(),
emitted: () => wrapper.emitted(),
rerender: (props: Record<string, unknown>) => wrapper.setProps(props),
...getQueriesForElement(baseElement as HTMLElement),
} as RenderResult
}
function unwrapNode (node: HTMLElement) {
node.replaceWith(...node.childNodes)
}
function cleanup () {
mountedWrappers.forEach(cleanupAtWrapper)
}
function cleanupAtWrapper (wrapper: VueWrapper<any>) {
if (
wrapper.element.parentNode &&
wrapper.element.parentNode.parentNode === document.body
) {
document.body.removeChild(wrapper.element.parentNode)
}
wrapper.unmount()
mountedWrappers.delete(wrapper)
}
afterEach(() => {
cleanup()
})
export { renderAsync }