chore: update eslint rules

This commit is contained in:
mutoe 2023-12-11 18:34:08 +08:00
parent d604eaa4ef
commit ac3c831e2b
No known key found for this signature in database
61 changed files with 942 additions and 869 deletions

View File

@ -1,10 +0,0 @@
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
sourceType: 'module',
extraFileExtensions: ['.vue'],
},
extends: '@mutoe/eslint-config-preset-vue',
}

View File

@ -6,13 +6,13 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
directory: /
schedule:
interval: monthly
allow:
- dependency-type: production
- package-ecosystem: github-actions
directory: ".github/workflows"
directory: .github/workflows
schedule:
interval: monthly

View File

@ -9,14 +9,14 @@
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
name: CodeQL
on:
push:
branches: [ master ]
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
branches: [master]
schedule:
- cron: '43 3 * * 6'
@ -28,7 +28,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: [javascript]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
@ -59,7 +59,7 @@ jobs:
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# - run: |
# make bootstrap
# make release

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
@ -55,7 +55,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
@ -102,7 +102,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install
@ -55,7 +55,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install
@ -102,7 +102,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'pnpm'
cache: pnpm
- name: Install dependencies
run: pnpm install

View File

@ -34,7 +34,6 @@ Instead of having the Bootstrap theme included locally, we recommend loading the
Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template).
# Templates
- [Layout](#layout)
@ -48,10 +47,8 @@ Alternatively, if you want to make modifications to the theme, check out the [th
- [Create/Edit Article](#createedit-article)
- [Article](#article)
## Layout
### Header
```html
@ -93,7 +90,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</div>
</nav>
```
### Footer
@ -319,7 +315,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</a>
</div>
</div>
</div>
@ -400,7 +395,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</div>
</div>
```
### Article

View File

@ -9,7 +9,6 @@
- [Demo](https://vue3-realworld-example-app-mutoe.vercel.app)
- [RealWorld](https://github.com/gothinkster/realworld)
This codebase was created to demonstrate a fully fledged fullstack application built with **Vue3** including CRUD operations, authentication, routing, pagination, and more.
We've gone to great lengths to adhere to the **Vue3** community styleguides & best practices.

View File

@ -1,20 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'@mutoe/eslint-config-preset-ts',
'plugin:cypress/recommended',
],
env: {
'cypress/globals': true,
},
overrides: [
{
files: ['support/**/*.ts'],
rules: {
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/consistent-type-imports': 'error',
},
},
],
}

View File

@ -1,6 +1,6 @@
import { ROUTES } from './constant'
describe('Article', () => {
describe('article', () => {
beforeEach(() => {
cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
cy.intercept('GET', /articles\/.+/, { fixture: 'article.json' })

View File

@ -1,13 +1,13 @@
import { ROUTES } from './constant'
describe('Auth', () => {
describe('auth', () => {
beforeEach(() => {
cy.intercept('GET', /users/, { fixture: 'user.json' }).as('getUser')
cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags')
cy.intercept('GET', /articles/, { fixture: 'articles.json' }).as('getArticles')
})
describe('Login and logout', () => {
describe('login and logout', () => {
it('should login success when submit a valid login form', () => {
cy.login()
@ -75,7 +75,7 @@ describe('Auth', () => {
})
})
describe('Register', () => {
describe('register', () => {
it('should call register API and jump to home page when submit a valid form', () => {
cy.intercept('POST', /users$/, { fixture: 'user.json' }).as('registerRequest')
cy.visit(ROUTES.REGISTER)

View File

@ -1,6 +1,6 @@
import { ROUTES } from './constant'
describe('Favorite', () => {
describe('favorite', () => {
beforeEach(() => {
cy.intercept('GET', /articles\?/, { fixture: 'articles.json' }).as('getArticles')
cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags')

View File

@ -1,11 +1,11 @@
import { ROUTES } from './constant'
describe.only('Follow', () => {
describe.only('follow', () => {
beforeEach(() => {
cy.intercept('GET', /articles\?/, { fixture: 'articles.json' }).as('getArticles')
cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags')
cy.intercept('GET', /profiles\/\S+/, { fixture: 'profile.json' }).as('getProfile')
cy.fixture('article.json').then(article => {
cy.fixture('article.json').then((article) => {
article.article.author.username = 'foo'
cy.intercept('GET', /articles\/\S+/, { statusCode: 200, body: article }).as('getArticle')
})
@ -21,7 +21,7 @@ describe.only('Follow', () => {
})
it('should call follow user api when click follow user button', () => {
cy.fixture('profile.json').then(profile => {
cy.fixture('profile.json').then((profile) => {
profile.profile.following = true
cy.intercept('POST', /profiles\/\S+\/follow/, { statusCode: 200, body: profile }).as('followUser')
})

View File

@ -1,6 +1,6 @@
import { ROUTES } from './constant'
describe('Homepage', () => {
describe('homepage', () => {
beforeEach(() => {
cy.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' }).as('getArticlesOfTag')
cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' }).as('getArticles')

View File

@ -1,6 +1,6 @@
import { ROUTES } from './constant'
describe('Tag', () => {
describe('tag', () => {
beforeEach(() => {
cy.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' }).as('getArticlesOfTag')
cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' }).as('getArticles')
@ -31,7 +31,7 @@ describe('Tag', () => {
.should('have.class', 'router-link-active')
.should('have.class', 'router-link-exact-active')
cy.get('a.tag-pill.tag-default:last').invoke('text').then(tag => {
cy.get('a.tag-pill.tag-default:last').invoke('text').then((tag) => {
const path = `#/tag/${tag}`
cy.url()

View File

@ -1,17 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"baseUrl": ".",
"types": ["cypress", "@testing-library/cypress"],
"isolatedModules": false
},
"include": [
"./**/*",
"../fixtures/**/*",
"../support/commands.ts",
"../support/e2e.ts"
],
"exclude": ["../../src"],
"compilerOptions": {
"baseUrl": ".",
"isolatedModules": false,
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"types": ["cypress", "@testing-library/cypress"]
}
"exclude": [
"../../src"
]
}

View File

@ -1,3 +1,5 @@
/* eslint-disable ts/no-unsafe-member-access,ts/no-unsafe-assignment */
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
@ -27,7 +29,7 @@ declare global {
}
Cypress.Commands.add('login', (username = 'plumrx') => {
cy.fixture('user.json').then(authResponse => {
cy.fixture('user.json').then((authResponse) => {
authResponse.user.username = username
cy.intercept('POST', /users\/login$/, { statusCode: 200, body: authResponse })
})

24
eslint.config.js Normal file
View File

@ -0,0 +1,24 @@
import defineConfig from '@mutoe/eslint-config'
export default defineConfig({
typescript: {
tsconfigPath: [
'tsconfig.json',
'tsconfig.node.json',
'cypress/e2e/tsconfig.json',
],
},
vue: {
sfcBlocks: {
defaultLanguage: {
script: 'ts',
},
},
},
test: {
cypress: true,
},
ignores: [
'src/services/api.ts',
],
})

View File

@ -2,22 +2,29 @@
"name": "vue3-realworld-example-app",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"prepare": "simple-git-hooks",
"dev": "vite",
"build": "vite build",
"serve": "vite preview --port 4137",
"lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.ts}\"",
"lint:tsc": "vue-tsc --noEmit",
"lint": "concurrently \"npm run lint:tsc\" \"npm run lint:script\"",
"test:unit": "vitest run",
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
"type-check": "vue-tsc --noEmit",
"lint": "eslint --fix .",
"test": "npm run test:unit && npm run test:e2e:ci",
"test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4137\"",
"test:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173",
"test:e2e:prod": "cypress run --e2e -c baseUrl=https://vue3-realworld-example-app-mutoe.vercel.app",
"test:unit": "vitest run",
"generate:api": "curl -sL https://raw.githubusercontent.com/gothinkster/realworld/main/api/openapi.yml -o ./src/services/openapi.yml && sta -p ./src/services/openapi.yml -o ./src/services -n api.ts"
},
"simple-git-hooks": {
"pre-commit": "npm exec lint-staged",
"pre-push": "npm run lint && npm run build"
},
"lint-staged": {
"*": "eslint --fix"
},
"dependencies": {
"insane": "^2.6.2",
"marked": "^11.0.0",
@ -26,7 +33,8 @@
"vue-router": "^4.2.5"
},
"devDependencies": {
"@mutoe/eslint-config-preset-vue": "~3.5.3",
"@eslint/eslintrc": "^2.1.4",
"@mutoe/eslint-config": "^2.4.5",
"@pinia/testing": "^0.1.3",
"@testing-library/cypress": "^10.0.1",
"@testing-library/user-event": "^14.5.1",
@ -48,13 +56,5 @@
"vitest": "^1.0.0-beta.5",
"vitest-dom": "^0.1.1",
"vue-tsc": "^1.8.22"
},
"lint-staged": {
"src/**/*.{ts,vue,js}": "eslint --fix",
"cypress/**/*.{ts,js}": "eslint --fix"
},
"simple-git-hooks": {
"pre-commit": "npm exec lint-staged",
"pre-push": "npm run lint && npm run build"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -92,5 +92,4 @@ const allNavLinks = computed<NavLink[]>(() => [
const navLinks = computed(() => allNavLinks.value.filter(
l => l.display === displayStatus.value || l.display === 'all',
))
</script>

View File

@ -3,7 +3,8 @@
<li
v-for="pageNumber in pagesCount"
:key="pageNumber"
:class="['page-item', { active: isActive(pageNumber) }]"
class="page-item"
:class="[{ active: isActive(pageNumber) }]"
>
<a
:aria-label="`Go to page ${pageNumber}`"
@ -24,15 +25,14 @@ interface Props {
count: number
}
interface Emits {
(e: 'page-change', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const emit = defineEmits<{
(e: 'pageChange', index: number): void
}>()
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)
const onPageChange = (index: number) => emit('pageChange', index)
</script>

View File

@ -55,7 +55,7 @@ const article: Article = reactive(await api.articles.getArticle(slug).then(res =
const articleHandledBody = computed(() => marked(article.body))
const updateArticle = (newArticle: Article) => {
function updateArticle(newArticle: Article) {
Object.assign(article, newArticle)
}
</script>

View File

@ -26,7 +26,7 @@ describe('# ArticleDetailComment', () => {
expect(getByRole('button', { name: 'Delete comment' })).toBeInTheDocument()
})
it('should emit remove comment when click remove comment button', () => {
it('should emit remove comment when click remove comment button', async () => {
const onRemoveComment = vi.fn()
const { getByRole } = render(ArticleDetailComment, renderOptions({
props: {
@ -36,7 +36,7 @@ describe('# ArticleDetailComment', () => {
},
}))
fireEvent.click(getByRole('button', { name: 'Delete comment' }))
await fireEvent.click(getByRole('button', { name: 'Delete comment' }))
expect(onRemoveComment).toHaveBeenCalled()
})

View File

@ -9,7 +9,7 @@
<div class="card-footer">
<AppLink
name="profile"
:params="{username: comment.author.username}"
:params="{ username: comment.author.username }"
class="comment-author"
>
<img
@ -23,7 +23,7 @@
<AppLink
name="profile"
:params="{username: comment.author.username}"
:params="{ username: comment.author.username }"
class="comment-author"
>
{{ comment.author.username }}
@ -54,12 +54,11 @@ interface Props {
comment: Comment
username?: string
}
const props = defineProps<Props>()
interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'remove-comment'): boolean
}
const emit = defineEmits<Emits>()
}>()
const showRemove = computed(() => props.username !== undefined && props.username === props.comment.author.username)
</script>

View File

@ -32,15 +32,14 @@ const username = computed(() => user.value?.username)
const comments = ref<Comment[]>([])
const addComment = async (comment: Comment) => {
async function addComment(comment: Comment) {
comments.value.unshift(comment)
}
const removeComment = async (commentId: number) => {
async function removeComment(commentId: number) {
await api.articles.deleteArticleComment(slug, commentId)
comments.value = comments.value.filter(c => c.id !== commentId)
}
comments.value = await api.articles.getArticleComments(slug).then(res => res.data.comments)
</script>

View File

@ -49,12 +49,11 @@ import { useUserStore } from 'src/store/user'
interface Props {
articleSlug: string
}
interface Emits {
(e: 'add-comment', comment: Comment): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const emit = defineEmits<{
(e: 'addComment', comment: Comment): void
}>()
const { user } = storeToRefs(useUserStore())
@ -63,12 +62,11 @@ const { profile } = useProfile({ username })
const comment = ref('')
const submitComment = async () => {
async function submitComment() {
const newComment = await api.articles
.createArticleComment(props.articleSlug, { comment: { body: comment.value } })
.then(res => res.data.comment)
emit('add-comment', newComment)
emit('addComment', newComment)
comment.value = ''
}
</script>

View File

@ -2,7 +2,7 @@
<div class="article-meta">
<AppLink
name="profile"
:params="{username: article.author.username}"
:params="{ username: article.author.username }"
>
<img :src="article.author.image" :alt="article.author.username">
</AppLink>
@ -10,7 +10,7 @@
<div class="info">
<AppLink
name="profile"
:params="{username: article.author.username}"
:params="{ username: article.author.username }"
class="author"
>
{{ article.author.username }}
@ -33,7 +33,7 @@
<button
:aria-label="article.favorited ? 'Unfavorite article' : 'Favorite article'"
class="btn btn-sm space"
:class="[article.favorited ? 'btn-primary':'btn-outline-primary']"
:class="[article.favorited ? 'btn-primary' : 'btn-outline-primary']"
:disabled="favoriteProcessGoing"
@click="favoriteArticle"
>
@ -47,7 +47,7 @@
aria-label="Edit article"
class="btn btn-outline-secondary btn-sm space"
name="edit-article"
:params="{slug: article.slug}"
:params="{ slug: article.slug }"
>
<i class="ion-edit space" /> Edit Article
</AppLink>
@ -94,7 +94,7 @@ const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
onUpdate: newArticle => emit('update', newArticle),
})
const onDelete = async () => {
async function onDelete() {
await api.articles.deleteArticle(article.value.slug)
await routerPush('global-feed')
}

View File

@ -52,5 +52,4 @@ const {
} = useArticles()
await fetchArticles()
</script>

View File

@ -3,14 +3,14 @@
<div class="article-meta">
<AppLink
name="profile"
:params="{username: props.article.author.username}"
:params="{ username: props.article.author.username }"
>
<img :src="article.author.image" :alt="props.article.author.username">
</AppLink>
<div class="info">
<AppLink
name="profile"
:params="{username: props.article.author.username}"
:params="{ username: props.article.author.username }"
class="author"
>
{{ article.author.username }}
@ -21,9 +21,9 @@
<button
:aria-label="article.favorited ? 'Unfavorite article' : 'Favorite article'"
class="btn btn-sm pull-xs-right"
:class="[article.favorited ? 'btn-primary':'btn-outline-primary']"
:class="[article.favorited ? 'btn-primary' : 'btn-outline-primary']"
:disabled="favoriteProcessGoing"
@click="() =>favoriteArticle()"
@click="() => favoriteArticle()"
>
<i class="ion-heart" /> {{ article.favoritesCount }}
</button>
@ -31,7 +31,7 @@
<AppLink
name="article"
:params="{slug: props.article.slug}"
:params="{ slug: props.article.slug }"
class="preview-link"
>
<h1>{{ article.title }}</h1>
@ -73,5 +73,4 @@ const {
articleSlug: computed(() => props.article.slug),
onUpdate: (newArticle: Article): void => emit('update', newArticle),
})
</script>

View File

@ -99,5 +99,4 @@ const show = computed<Record<ArticlesType, boolean>>(() => ({
}))
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
</script>

View File

@ -6,7 +6,7 @@
v-for="tag in tags"
:key="tag"
name="tag"
:params="{tag}"
:params="{ tag }"
:aria-label="tag"
class="tag-pill tag-default"
>

View File

@ -6,30 +6,34 @@ import { api, pageToOffset } from 'src/services'
import type { Article } from 'src/services/api'
import useAsync from 'src/utils/use-async'
export function useArticles () {
export function useArticles() {
const { articlesType, tag, username, metaChanged } = useArticlesMeta()
const articles = ref<Article[]>([])
const articlesCount = ref(0)
const page = ref(1)
async function fetchArticles (): Promise<void> {
async function fetchArticles(): Promise<void> {
articles.value = []
let responsePromise: null | Promise<{ articles: Article[], articlesCount: number }> = null
if (articlesType.value === 'my-feed') {
responsePromise = api.articles.getArticlesFeed(pageToOffset(page.value))
.then(res => res.data)
} else if (articlesType.value === 'tag-feed' && tag.value) {
}
else if (articlesType.value === 'tag-feed' && tag.value) {
responsePromise = api.articles.getArticles({ tag: tag.value, ...pageToOffset(page.value) })
.then(res => res.data)
} else if (articlesType.value === 'user-feed' && username.value) {
}
else if (articlesType.value === 'user-feed' && username.value) {
responsePromise = api.articles.getArticles({ author: username.value, ...pageToOffset(page.value) })
.then(res => res.data)
} else if (articlesType.value === 'user-favorites-feed' && username.value) {
}
else if (articlesType.value === 'user-favorites-feed' && username.value) {
responsePromise = api.articles.getArticles({ favorited: username.value, ...pageToOffset(page.value) })
.then(res => res.data)
} else if (articlesType.value === 'global-feed') {
}
else if (articlesType.value === 'global-feed') {
responsePromise = api.articles.getArticles(pageToOffset(page.value))
.then(res => res.data)
}
@ -55,11 +59,10 @@ export function useArticles () {
const { active: articlesDownloading, run: runWrappedFetchArticles } = useAsync(fetchArticles)
watch(metaChanged, async () => {
if (page.value === 1) {
if (page.value === 1)
await runWrappedFetchArticles()
} else {
else
changePage(1)
}
})
watch(page, runWrappedFetchArticles)
@ -81,7 +84,7 @@ export type ArticlesType = 'global-feed' | 'my-feed' | 'tag-feed' | 'user-feed'
export const articlesTypes: ArticlesType[] = ['global-feed', 'my-feed', 'tag-feed', 'user-feed', 'user-favorites-feed']
export const isArticlesType = (type: any): type is ArticlesType => articlesTypes.includes(type)
export const isArticlesType = (type: unknown): type is ArticlesType => articlesTypes.includes(type as ArticlesType)
const routeNameToArticlesType: Partial<Record<AppRouteNames, ArticlesType>> = {
'global-feed': 'global-feed',
@ -97,7 +100,7 @@ interface UseArticlesMetaReturn {
articlesType: ComputedRef<ArticlesType>
metaChanged: ComputedRef<string>
}
function useArticlesMeta (): UseArticlesMetaReturn {
function useArticlesMeta(): UseArticlesMetaReturn {
const route = useRoute()
const tag = ref('')
@ -106,9 +109,10 @@ function useArticlesMeta (): UseArticlesMetaReturn {
watch(
() => route.name,
routeName => {
(routeName) => {
const possibleArticlesType = routeNameToArticlesType[routeName as AppRouteNames]
if (!isArticlesType(possibleArticlesType)) return
if (!isArticlesType(possibleArticlesType))
return
articlesType.value = possibleArticlesType
},
@ -117,20 +121,18 @@ function useArticlesMeta (): UseArticlesMetaReturn {
watch(
() => route.params.username,
usernameParam => {
if (usernameParam !== username.value) {
(usernameParam) => {
if (usernameParam !== username.value)
username.value = typeof usernameParam === 'string' ? usernameParam : ''
}
},
{ immediate: true },
)
watch(
() => route.params.tag,
tagParam => {
if (tagParam !== tag.value) {
(tagParam) => {
if (tagParam !== tag.value)
tag.value = typeof tagParam === 'string' ? tagParam : ''
}
},
{ immediate: true },
)

View File

@ -9,7 +9,7 @@ interface useFavoriteArticleProps {
onUpdate: (newArticle: Article) => void
}
export const useFavoriteArticle = ({ isFavorited, articleSlug, onUpdate }: useFavoriteArticleProps) => {
export function useFavoriteArticle({ isFavorited, articleSlug, onUpdate }: useFavoriteArticleProps) {
const favoriteArticle = async () => {
const requestor = isFavorited.value ? api.articles.deleteArticleFavorite : api.articles.createArticleFavorite
const article = await requestor(articleSlug.value).then(res => res.data.article)

View File

@ -9,8 +9,8 @@ interface UseFollowProps {
onUpdate: (profile: Profile) => void
}
export function useFollow ({ username, following, onUpdate }: UseFollowProps) {
async function toggleFollow () {
export function useFollow({ username, following, onUpdate }: UseFollowProps) {
async function toggleFollow() {
const requester = following.value ? api.profiles.unfollowUserByUsername : api.profiles.followUserByUsername
const profile = await requester(username.value).then(res => res.data.profile)
onUpdate(profile)

View File

@ -7,17 +7,19 @@ interface UseProfileProps {
username: ComputedRef<string>
}
export function useProfile ({ username }: UseProfileProps) {
export function useProfile({ username }: UseProfileProps) {
const profile = ref<Profile | null>(null)
async function fetchProfile (): Promise<void> {
async function fetchProfile(): Promise<void> {
updateProfile(null)
if (!username.value) return
if (!username.value)
return
const profileData = await api.profiles.getProfileByUsername(username.value).then(res => res.data.profile)
updateProfile(profileData)
}
function updateProfile (profileData: Profile | null): void {
function updateProfile(profileData: Profile | null): void {
profile.value = profileData
}

View File

@ -1,10 +1,10 @@
import { ref } from 'vue'
import { api } from 'src/services'
export function useTags () {
export function useTags() {
const tags = ref<string[]>([])
async function fetchTags (): Promise<void> {
async function fetchTags(): Promise<void> {
tags.value = []
tags.value = await api.tags.getTags().then(({ data }) => data.tags)
}

View File

@ -1,3 +1,3 @@
export const CONFIG = {
API_HOST: import.meta.env.VITE_API_HOST || '',
API_HOST: String(import.meta.env.VITE_API_HOST) || '',
}

View File

@ -98,15 +98,15 @@ const form: FormState = reactive({
})
const newTag = ref<string>('')
const addTag = () => {
function addTag() {
form.tagList.push(newTag.value.trim())
newTag.value = ''
}
const removeTag = (tag: string) => {
function removeTag(tag: string) {
form.tagList = form.tagList.filter(t => t !== tag)
}
async function fetchArticle (slug: string) {
async function fetchArticle(slug: string) {
const article = await api.articles.getArticle(slug).then(res => res.data.article)
// FIXME: I always feel a little wordy here
@ -116,18 +116,18 @@ async function fetchArticle (slug: string) {
form.tagList = article.tagList
}
onMounted(() => {
if (slug.value) fetchArticle(slug.value)
onMounted(async () => {
if (slug.value)
await fetchArticle(slug.value)
})
const onSubmit = async () => {
async function onSubmit() {
let article: Article
if (slug.value) {
if (slug.value)
article = await api.articles.updateArticle(slug.value, { article: form }).then(res => res.data.article)
} else {
else
article = await api.articles.createArticle({ article: form }).then(res => res.data.article)
}
return router.push({ name: 'article', params: { slug: article.slug } })
}
</script>

View File

@ -51,7 +51,7 @@ describe('# Login page', () => {
it('should not trigger api call when user submit a invalid form', async () => {
const { getByRole, getByPlaceholderText } = render(Login, renderOptions())
const formElement = getByRole('form', { name: 'Login form' }) as HTMLFormElement
const formElement = getByRole<HTMLFormElement>('form', { name: 'Login form' })
vi.spyOn(formElement, 'checkValidity')
expect(getByRole('button', { name: 'Sign in' })).toHaveProperty('disabled', true)

View File

@ -80,16 +80,18 @@ const { updateUser } = useUserStore()
const errors = ref()
const login = async () => {
async function login() {
errors.value = {}
if (!formRef.value?.checkValidity()) return
if (!formRef.value?.checkValidity())
return
try {
const result = await api.users.login({ user: form })
updateUser(result.data.user)
await routerPush('global-feed')
} catch (error) {
}
catch (error) {
if (isFetchError(error)) {
errors.value = error.error?.errors
return
@ -97,5 +99,4 @@ const login = async () => {
console.error(error)
}
}
</script>

View File

@ -91,7 +91,6 @@ const { user, isAuthorized } = storeToRefs(useUserStore())
const showEdit = computed<boolean>(() => isAuthorized && user.value?.username === username.value)
const showFollow = computed<boolean>(() => user.value?.username !== username.value)
</script>
<style scoped>

View File

@ -56,7 +56,7 @@ describe('# Register form', () => {
it('should not trigger api call when user submit a invalid form', async () => {
const { getByRole, getByPlaceholderText } = render(Register, renderOptions())
const formElement = getByRole('form', { name: 'Registration form' }) as HTMLFormElement
const formElement = getByRole<HTMLFormElement>('form', { name: 'Registration form' })
vi.spyOn(formElement, 'checkValidity')
expect(getByRole('button', { name: 'Sign up' })).toHaveProperty('disabled', true)

View File

@ -89,19 +89,20 @@ const { updateUser } = useUserStore()
const errors = ref()
const register = async () => {
async function register() {
errors.value = {}
if (!formRef.value?.checkValidity()) return
if (!formRef.value?.checkValidity())
return
try {
const result = await api.users.createUser({ user: form })
updateUser(result.data.user)
await routerPush('global-feed')
} catch (error) {
if (isFetchError(error)) {
errors.value = error.error?.errors
}
catch (error) {
if (isFetchError(error))
errors.value = error.error?.errors
}
}
</script>

View File

@ -97,29 +97,30 @@ const form: UpdateUser = reactive({})
const userStore = useUserStore()
const errors = ref()
const onSubmit = async () => {
async function onSubmit() {
errors.value = {}
try {
// eslint-disable-next-line unicorn/no-array-reduce
// eslint-disable-next-line unicorn/no-array-reduce, ts/no-unsafe-assignment
const filteredForm = Object.entries(form).reduce((form, [k, v]) => v === null ? form : Object.assign(form, { [k]: v }), {})
const userData = await api.user.updateCurrentUser({ user: filteredForm }).then(res => res.data.user)
userStore.updateUser(userData)
await routerPush('profile', { username: userData.username })
} catch (error) {
if (isFetchError(error)) {
errors.value = error.error?.errors
}
catch (error) {
if (isFetchError(error))
errors.value = error.error?.errors
}
}
const onLogout = async () => {
async function onLogout() {
userStore.updateUser(null)
await routerPush('global-feed')
}
onMounted(async () => {
if (!userStore.isAuthorized) return await routerPush('login')
if (!userStore.isAuthorized)
return await routerPush('login')
form.image = userStore.user?.image
form.username = userStore.user?.username
@ -134,5 +135,4 @@ const isButtonDisabled = computed(() =>
&& form.email === userStore.user?.email
&& !form.password,
)
</script>

View File

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

View File

@ -1,11 +1,13 @@
/* eslint-disable array-element-newline */
/* eslint-disable antfu/consistent-list-newline */
import insane from 'insane'
import { marked } from 'marked'
export default (markdown?: string): string => {
if (!markdown) return ''
if (!markdown)
return ''
const html = marked(markdown)
// eslint-disable-next-line ts/no-unsafe-return,ts/no-unsafe-call
return insane(html, {
allowedTags: ['a', 'article', 'b', 'blockquote', 'br', 'caption', 'code', 'del', 'details', 'div', 'em',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'input', 'ins', 'kbd', 'li', 'main',
@ -28,11 +30,11 @@ export default (markdown?: string): string => {
td: ['align'],
input: ['disabled', 'type', 'checked'],
},
filter: ({ tag, attrs }: {tag: string, attrs: Record<string, string>}) => {
filter: ({ tag, attrs }: { tag: string, attrs: Record<string, string> }) => {
// Display checklist
if (tag === 'input') {
if (tag === 'input')
return attrs.type === 'checkbox' && attrs.disabled === ''
}
return true
},
})

View File

@ -3,5 +3,6 @@ import { userStorage } from 'src/store/user'
export default function (): void {
const token = userStorage.get()?.token
if (token !== undefined) api.setSecurityData(token)
if (token !== undefined)
api.setSecurityData(token)
}

View File

@ -80,7 +80,7 @@ export const router = createRouter({
routes,
})
export function routerPush (name: AppRouteNames, params?: RouteParams): ReturnType<typeof router.push> {
export function routerPush(name: AppRouteNames, params?: RouteParams): ReturnType<typeof router.push> {
return params === undefined
? router.push({ name })
: router.push({ name, params })

View File

@ -6,7 +6,7 @@ export const limit = 10
export const api = new Api({
baseUrl: `${CONFIG.API_HOST}/api`,
securityWorker: token => token ? { headers: { Authorization: `Bearer ${token}` } } : {},
securityWorker: token => token ? { headers: { Authorization: `Bearer ${String(token)}` } } : {},
baseApiParams: {
headers: {
'content-type': ContentType.Json,
@ -15,11 +15,11 @@ export const api = new Api({
},
})
export function pageToOffset (page: number = 1, localLimit = limit): {limit: number, offset: number} {
export function pageToOffset(page: number = 1, localLimit = limit): { limit: number, offset: number } {
const offset = (page - 1) * localLimit
return { limit: localLimit, offset }
}
export function isFetchError<E = GenericErrorModel> (e: unknown): e is HttpResponse<unknown, E> {
export function isFetchError<E = GenericErrorModel>(e: unknown): e is HttpResponse<unknown, E> {
return e instanceof Object && 'error' in e
}

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="vitest-dom/extend-expect" />
import 'vitest-dom/extend-expect'

View File

@ -12,12 +12,13 @@ export const useUserStore = defineStore('user', () => {
const user = ref(userStorage.get())
const isAuthorized = computed(() => !!user.value)
function updateUser (userData?: User | null) {
function updateUser(userData?: User | null) {
if (userData) {
userStorage.set(userData)
api.setSecurityData(userData.token)
user.value = userData
} else {
}
else {
userStorage.remove()
api.setSecurityData(null)
user.value = null

View File

@ -1,9 +1,6 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
import type AppLink from 'src/components/AppLink.vue'
declare module '@vue/runtime-core' {
// noinspection JSUnusedGlobalSymbols
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@ -1,4 +1,4 @@
export const dateFilter = (dateString: string): string => {
export function dateFilter(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'long',

View File

@ -1,3 +1,3 @@
export default function params2query (params: Record<string, string | number | boolean>): string {
export default function params2query(params: Record<string, string | number | boolean>): string {
return Object.entries(params).map(([key, value]) => `${key}=${value.toString()}`).join('&')
}

View File

@ -4,26 +4,27 @@ export default class Storage<T = unknown> {
private readonly key: string
private readonly storageType: StorageType
constructor (key: string, storageType: StorageType = 'localStorage') {
constructor(key: string, storageType: StorageType = 'localStorage') {
this.key = key
this.storageType = storageType
}
get (): T | null {
get(): T | null {
try {
const value = window[this.storageType].getItem(this.key) ?? ''
return JSON.parse(value)
} catch {
return JSON.parse(value) as T
}
catch {
return null
}
}
set (value: T): void {
set(value: T): void {
const strValue = JSON.stringify(value)
window[this.storageType].setItem(this.key, strValue)
}
remove (): void {
remove(): void {
window[this.storageType].removeItem(this.key)
}
}

View File

@ -10,10 +10,12 @@ import { afterAll, afterEach, beforeAll } from 'vitest'
import AppLink from 'src/components/AppLink.vue'
import { routes } from 'src/router'
export const createTestRouter = (base?: string): Router => createRouter({
export function createTestRouter(base?: string): Router {
return createRouter({
routes,
history: createMemoryHistory(base),
})
})
}
interface RenderOptionsArgs {
props: Record<string, unknown>
@ -28,16 +30,16 @@ interface RenderOptionsArgs {
const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout
export function flushPromises (): Promise<void> {
export function flushPromises(): Promise<void> {
return new Promise((resolve) => {
scheduler(resolve, 0)
})
}
export function renderOptions (): RenderOptions
export function renderOptions (args: Partial<Omit<RenderOptionsArgs, 'initialRoute'>>): RenderOptions
export async function renderOptions (args: (Partial<RenderOptionsArgs> & {initialRoute: RouteLocationRaw})): Promise<RenderOptions>
export function renderOptions (args: Partial<RenderOptionsArgs> = {}): RenderOptions | Promise<RenderOptions> {
export function renderOptions(): RenderOptions
export function renderOptions(args: Partial<Omit<RenderOptionsArgs, 'initialRoute'>>): RenderOptions
export async function renderOptions(args: (Partial<RenderOptionsArgs> & { initialRoute: RouteLocationRaw })): Promise<RenderOptions>
export function renderOptions(args: Partial<RenderOptionsArgs> = {}): RenderOptions | Promise<RenderOptions> {
const router = args.router || createTestRouter()
const result = {
@ -52,28 +54,31 @@ export function renderOptions (args: Partial<RenderOptionsArgs> = {}): RenderOpt
...args.initialState,
},
stubActions: args.stubActions ?? false,
})],
}),
],
components: { AppLink },
},
}
const { initialRoute } = args
if (!initialRoute) return result
if (!initialRoute)
return result
return new Promise((resolve) => {
router.replace(initialRoute).then(() => resolve(result))
void router.replace(initialRoute).then(() => resolve(result))
})
}
export function asyncWrapper (component: ReturnType<typeof defineComponent>, props?: Record<string, unknown>): ReturnType<typeof defineComponent> {
export function asyncWrapper(component: ReturnType<typeof defineComponent>, props?: Record<string, unknown>): ReturnType<typeof defineComponent> {
return defineComponent({
render () {
render() {
return h(
'div',
{ id: 'root' },
h(Suspense, null, {
default () {
default() {
// eslint-disable-next-line ts/no-unsafe-argument
return h(component, props)
},
fallback: h('div', 'Loading...'),
@ -83,7 +88,7 @@ export function asyncWrapper (component: ReturnType<typeof defineComponent>, pro
})
}
async function waitForServerRequest (server: SetupServer, method: string, url: string, flush = true): Promise<Request> {
async function waitForServerRequest(server: SetupServer, method: string, url: string, flush = true): Promise<Request> {
let expectedRequestId = ''
let expectedRequest: Request
@ -98,11 +103,13 @@ async function waitForServerRequest (server: SetupServer, method: string, url: s
})
server.events.on('response:mocked', ({ requestId: reqId }) => {
if (reqId === expectedRequestId) resolve(expectedRequest)
if (reqId === expectedRequestId)
resolve(expectedRequest)
})
server.events.on('request:unhandled', ({ request: req, requestId: reqId }) => {
if (reqId === expectedRequestId) reject(new Error(`The ${req.method} ${req.url} request was unhandled.`))
if (reqId === expectedRequestId)
reject(new Error(`The ${req.method} ${req.url} request was unhandled.`))
})
})
flush && await flushPromises()
@ -139,16 +146,20 @@ type Listener =
* })
*/
export function setupMockServer (...listeners: Listener[]) {
export function setupMockServer(...listeners: Listener[]) {
const parseArgs = (args: Listener): [string, string, number, (object | null)] => {
if (args.length === 4) return args
if (args.length === 4)
return args
if (args.length === 3) {
if (typeof args[1] === 'number') return ['all', args[0], args[1], args[2] as object] // ['all', path, 200, object]
if (typeof args[2] === 'number') return [args[0], args[1], args[2], null] // [method, path, status, null]
if (typeof args[1] === 'number')
return ['all', args[0], args[1], args[2] as object] // ['all', path, 200, object]
if (typeof args[2] === 'number')
return [args[0], args[1], args[2], null] // [method, path, status, null]
return [args[0], args[1], 200, args[2]] // [method, path, 200, object]
}
if (args.length === 2) {
if (typeof args[1] === 'string') return [args[0], args[1], 200, null]
if (typeof args[1] === 'string')
return [args[0], args[1], 200, null]
return ['all', args[0], 200, args[1]]
}
return ['all', args[0], 200, null]
@ -168,11 +179,11 @@ export function setupMockServer (...listeners: Listener[]) {
afterEach(() => void server.resetHandlers())
afterAll(() => void server.close())
async function waitForRequest (path: string): Promise<Request>
async function waitForRequest (path: string, flush: boolean): Promise<Request>
async function waitForRequest (method: HttpMethod, path: string): Promise<Request>
async function waitForRequest (method: HttpMethod, path: string, flush: boolean): Promise<Request>
async function waitForRequest (...args: [string] | [string, boolean] | [HttpMethod, string] | [HttpMethod, string, boolean]): Promise<Request> {
async function waitForRequest(path: string): Promise<Request>
async function waitForRequest(path: string, flush: boolean): Promise<Request>
async function waitForRequest(method: HttpMethod, path: string): Promise<Request>
async function waitForRequest(method: HttpMethod, path: string, flush: boolean): Promise<Request>
async function waitForRequest(...args: [string] | [string, boolean] | [HttpMethod, string] | [HttpMethod, string, boolean]): Promise<Request> {
const [method, path, flush] = args.length === 1
? ['all', args[0]] // ['all', path]
: args.length === 2 && typeof args[1] === 'boolean'
@ -185,7 +196,7 @@ export function setupMockServer (...listeners: Listener[]) {
const originalUse = server.use.bind(server)
function use (...listeners: Listener[]) {
function use(...listeners: Listener[]) {
originalUse(
...listeners.map((args) => {
let [method, path, status, response] = parseArgs(args)

View File

@ -9,7 +9,7 @@ interface UseAsync<T extends (...args: unknown[]) => unknown> {
run: (...args: Parameters<T>) => Promise<ReturnType<T>>
}
export default function useAsync<T extends (...args: unknown[]) => unknown> (fn: T): UseAsync<T> {
export default function useAsync<T extends (...args: unknown[]) => unknown>(fn: T): UseAsync<T> {
const active: UseAsync<T>['active'] = ref(false)
const run: UseAsync<T>['run'] = async (...args) => {
@ -17,14 +17,16 @@ export default function useAsync<T extends (...args: unknown[]) => unknown> (fn:
try {
const result = await fn(...args)
return result as ReturnType<T>
} catch (error) {
}
catch (error) {
if (isFetchError(error) && error.status === 401) {
userStorage.remove()
await routerPush('login')
throw new Error('Unauthorized or token expired')
}
throw error
} finally {
}
finally {
active.value = false
}
}

View File

@ -1,28 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }],
"references": [
{ "path": "./tsconfig.node.json" }
],
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"ts-node": {
"compilerOptions": {
"module": "ESNext",

View File

@ -1,14 +1,14 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"skipLibCheck": true
},
"include": [
"vite.config.ts",
"cypress.config.ts",
".eslintrc.js"
"eslint.config.js"
]
}

View File

@ -1,9 +1,9 @@
/// <reference types="vitest" />
import { fileURLToPath, URL } from 'node:url'
import { URL, fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import analyzer from 'rollup-plugin-analyzer'
import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({