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:
commit
a2440d3dc5
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
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>
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -1,15 +1,12 @@
|
|||
# 
|
||||
|
||||
[](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
|
||||
[](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Amaster)
|
||||
[](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Ascript-setup)
|
||||
|
||||
[](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
|
||||
[](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/script-setup)
|
||||
[](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)_
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/App.vue
11
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -21,11 +21,3 @@
|
|||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppFooter',
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
Loading…
Reference in New Issue