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 version: 2
updates: updates:
- package-ecosystem: npm - package-ecosystem: npm
directory: "/" directory: /
schedule: schedule:
interval: monthly interval: monthly
allow: allow:
- dependency-type: production - dependency-type: production
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: ".github/workflows" directory: .github/workflows
schedule: schedule:
interval: monthly interval: monthly

View File

@ -9,14 +9,14 @@
# the `language` matrix defined below to confirm you have the correct set of # the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages. # supported CodeQL languages.
# #
name: "CodeQL" name: CodeQL
on: on:
push: push:
branches: [ master ] branches: [master]
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [ master ] branches: [master]
schedule: schedule:
- cron: '43 3 * * 6' - cron: '43 3 * * 6'
@ -28,40 +28,40 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'javascript' ] language: [javascript]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more: # 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 # 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
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file. # By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file. # Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main # queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project # and modify them (or add more) to build your code if your project
# uses a compiled language # uses a compiled language
#- run: | # - run: |
# make bootstrap # make bootstrap
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v2

View File

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

View File

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

View File

@ -25,7 +25,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
@ -55,7 +55,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
@ -102,7 +102,7 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: 'pnpm' cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install 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). Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template).
# Templates # Templates
- [Layout](#layout) - [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) - [Create/Edit Article](#createedit-article)
- [Article](#article) - [Article](#article)
## Layout ## Layout
### Header ### Header
```html ```html
@ -93,7 +90,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</div> </div>
</nav> </nav>
``` ```
### Footer ### Footer
@ -319,7 +315,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</a> </a>
</div> </div>
</div> </div>
</div> </div>
@ -400,7 +395,6 @@ Alternatively, if you want to make modifications to the theme, check out the [th
</div> </div>
</div> </div>
``` ```
### Article ### Article

View File

@ -9,7 +9,6 @@
- [Demo](https://vue3-realworld-example-app-mutoe.vercel.app) - [Demo](https://vue3-realworld-example-app-mutoe.vercel.app)
- [RealWorld](https://github.com/gothinkster/realworld) - [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. 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. 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' import { ROUTES } from './constant'
describe('Article', () => { describe('article', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' }) cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' })
cy.intercept('GET', /articles\/.+/, { fixture: 'article.json' }) cy.intercept('GET', /articles\/.+/, { fixture: 'article.json' })

View File

@ -1,13 +1,13 @@
import { ROUTES } from './constant' import { ROUTES } from './constant'
describe('Auth', () => { describe('auth', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', /users/, { fixture: 'user.json' }).as('getUser') cy.intercept('GET', /users/, { fixture: 'user.json' }).as('getUser')
cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags') cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags')
cy.intercept('GET', /articles/, { fixture: 'articles.json' }).as('getArticles') 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', () => { it('should login success when submit a valid login form', () => {
cy.login() 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', () => { 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.intercept('POST', /users$/, { fixture: 'user.json' }).as('registerRequest')
cy.visit(ROUTES.REGISTER) cy.visit(ROUTES.REGISTER)

View File

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

View File

@ -1,11 +1,11 @@
import { ROUTES } from './constant' import { ROUTES } from './constant'
describe.only('Follow', () => { describe.only('follow', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', /articles\?/, { fixture: 'articles.json' }).as('getArticles') cy.intercept('GET', /articles\?/, { fixture: 'articles.json' }).as('getArticles')
cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags') cy.intercept('GET', /tags/, { fixture: 'tags.json' }).as('getTags')
cy.intercept('GET', /profiles\/\S+/, { fixture: 'profile.json' }).as('getProfile') 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' article.article.author.username = 'foo'
cy.intercept('GET', /articles\/\S+/, { statusCode: 200, body: article }).as('getArticle') 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', () => { 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 profile.profile.following = true
cy.intercept('POST', /profiles\/\S+\/follow/, { statusCode: 200, body: profile }).as('followUser') cy.intercept('POST', /profiles\/\S+\/follow/, { statusCode: 200, body: profile }).as('followUser')
}) })

View File

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

View File

@ -1,6 +1,6 @@
import { ROUTES } from './constant' import { ROUTES } from './constant'
describe('Tag', () => { describe('tag', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' }).as('getArticlesOfTag') cy.intercept('GET', /articles\?tag=butt/, { fixture: 'articles-of-tag.json' }).as('getArticlesOfTag')
cy.intercept('GET', /articles\?limit/, { fixture: 'articles.json' }).as('getArticles') 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-active')
.should('have.class', 'router-link-exact-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}` const path = `#/tag/${tag}`
cy.url() cy.url()

View File

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

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 // This example support/index.js is processed and
// loaded automatically before your test files. // loaded automatically before your test files.
@ -27,7 +29,7 @@ declare global {
} }
Cypress.Commands.add('login', (username = 'plumrx') => { Cypress.Commands.add('login', (username = 'plumrx') => {
cy.fixture('user.json').then(authResponse => { cy.fixture('user.json').then((authResponse) => {
authResponse.user.username = username authResponse.user.username = username
cy.intercept('POST', /users\/login$/, { statusCode: 200, body: authResponse }) 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", "name": "vue3-realworld-example-app",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module",
"scripts": { "scripts": {
"prepare": "simple-git-hooks", "prepare": "simple-git-hooks",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview --port 4137", "serve": "vite preview --port 4137",
"lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.ts}\"", "type-check": "vue-tsc --noEmit",
"lint:tsc": "vue-tsc --noEmit", "lint": "eslint --fix .",
"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",
"test": "npm run test:unit && npm run test:e2e:ci", "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" "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": { "dependencies": {
"insane": "^2.6.2", "insane": "^2.6.2",
"marked": "^11.0.0", "marked": "^11.0.0",
@ -26,7 +33,8 @@
"vue-router": "^4.2.5" "vue-router": "^4.2.5"
}, },
"devDependencies": { "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", "@pinia/testing": "^0.1.3",
"@testing-library/cypress": "^10.0.1", "@testing-library/cypress": "^10.0.1",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
@ -48,13 +56,5 @@
"vitest": "^1.0.0-beta.5", "vitest": "^1.0.0-beta.5",
"vitest-dom": "^0.1.1", "vitest-dom": "^0.1.1",
"vue-tsc": "^1.8.22" "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( const navLinks = computed(() => allNavLinks.value.filter(
l => l.display === displayStatus.value || l.display === 'all', l => l.display === displayStatus.value || l.display === 'all',
)) ))
</script> </script>

View File

@ -3,7 +3,8 @@
<li <li
v-for="pageNumber in pagesCount" v-for="pageNumber in pagesCount"
:key="pageNumber" :key="pageNumber"
:class="['page-item', { active: isActive(pageNumber) }]" class="page-item"
:class="[{ active: isActive(pageNumber) }]"
> >
<a <a
:aria-label="`Go to page ${pageNumber}`" :aria-label="`Go to page ${pageNumber}`"
@ -24,15 +25,14 @@ interface Props {
count: number count: number
} }
interface Emits {
(e: 'page-change', index: number): void
}
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const emit = defineEmits<{
(e: 'pageChange', index: number): void
}>()
const { count, page } = toRefs(props) const { count, page } = toRefs(props)
const pagesCount = computed(() => Math.ceil(count.value / limit)) const pagesCount = computed(() => Math.ceil(count.value / limit))
const isActive = (index: number) => page.value === index const isActive = (index: number) => page.value === index
const onPageChange = (index: number) => emit('page-change', index) const onPageChange = (index: number) => emit('pageChange', index)
</script> </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 articleHandledBody = computed(() => marked(article.body))
const updateArticle = (newArticle: Article) => { function updateArticle(newArticle: Article) {
Object.assign(article, newArticle) Object.assign(article, newArticle)
} }
</script> </script>

View File

@ -26,7 +26,7 @@ describe('# ArticleDetailComment', () => {
expect(getByRole('button', { name: 'Delete comment' })).toBeInTheDocument() 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 onRemoveComment = vi.fn()
const { getByRole } = render(ArticleDetailComment, renderOptions({ const { getByRole } = render(ArticleDetailComment, renderOptions({
props: { 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() expect(onRemoveComment).toHaveBeenCalled()
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -97,29 +97,30 @@ const form: UpdateUser = reactive({})
const userStore = useUserStore() const userStore = useUserStore()
const errors = ref() const errors = ref()
const onSubmit = async () => { async function onSubmit() {
errors.value = {} errors.value = {}
try { 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 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) const userData = await api.user.updateCurrentUser({ user: filteredForm }).then(res => res.data.user)
userStore.updateUser(userData) userStore.updateUser(userData)
await routerPush('profile', { username: userData.username }) await routerPush('profile', { username: userData.username })
} catch (error) { }
if (isFetchError(error)) { catch (error) {
if (isFetchError(error))
errors.value = error.error?.errors errors.value = error.error?.errors
}
} }
} }
const onLogout = async () => { async function onLogout() {
userStore.updateUser(null) userStore.updateUser(null)
await routerPush('global-feed') await routerPush('global-feed')
} }
onMounted(async () => { onMounted(async () => {
if (!userStore.isAuthorized) return await routerPush('login') if (!userStore.isAuthorized)
return await routerPush('login')
form.image = userStore.user?.image form.image = userStore.user?.image
form.username = userStore.user?.username form.username = userStore.user?.username
@ -134,5 +135,4 @@ const isButtonDisabled = computed(() =>
&& form.email === userStore.user?.email && form.email === userStore.user?.email
&& !form.password, && !form.password,
) )
</script> </script>

View File

@ -1,6 +1,6 @@
import type { App } from 'vue' import type { App } from 'vue'
import AppLink from 'src/components/AppLink.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) 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 insane from 'insane'
import { marked } from 'marked' import { marked } from 'marked'
export default (markdown?: string): string => { export default (markdown?: string): string => {
if (!markdown) return '' if (!markdown)
return ''
const html = marked(markdown) const html = marked(markdown)
// eslint-disable-next-line ts/no-unsafe-return,ts/no-unsafe-call
return insane(html, { return insane(html, {
allowedTags: ['a', 'article', 'b', 'blockquote', 'br', 'caption', 'code', 'del', 'details', 'div', 'em', 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', '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'], td: ['align'],
input: ['disabled', 'type', 'checked'], input: ['disabled', 'type', 'checked'],
}, },
filter: ({ tag, attrs }: {tag: string, attrs: Record<string, string>}) => { filter: ({ tag, attrs }: { tag: string, attrs: Record<string, string> }) => {
// Display checklist // Display checklist
if (tag === 'input') { if (tag === 'input')
return attrs.type === 'checkbox' && attrs.disabled === '' return attrs.type === 'checkbox' && attrs.disabled === ''
}
return true return true
}, },
}) })

View File

@ -3,5 +3,6 @@ import { userStorage } from 'src/store/user'
export default function (): void { export default function (): void {
const token = userStorage.get()?.token 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, 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 return params === undefined
? router.push({ name }) ? router.push({ name })
: router.push({ name, params }) : router.push({ name, params })

View File

@ -6,7 +6,7 @@ export const limit = 10
export const api = new Api({ export const api = new Api({
baseUrl: `${CONFIG.API_HOST}/api`, baseUrl: `${CONFIG.API_HOST}/api`,
securityWorker: token => token ? { headers: { Authorization: `Bearer ${token}` } } : {}, securityWorker: token => token ? { headers: { Authorization: `Bearer ${String(token)}` } } : {},
baseApiParams: { baseApiParams: {
headers: { headers: {
'content-type': ContentType.Json, '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 const offset = (page - 1) * localLimit
return { limit: localLimit, offset } 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 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" /> /// <reference types="vitest-dom/extend-expect" />
import '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 user = ref(userStorage.get())
const isAuthorized = computed(() => !!user.value) const isAuthorized = computed(() => !!user.value)
function updateUser (userData?: User | null) { function updateUser(userData?: User | null) {
if (userData) { if (userData) {
userStorage.set(userData) userStorage.set(userData)
api.setSecurityData(userData.token) api.setSecurityData(userData.token)
user.value = userData user.value = userData
} else { }
else {
userStorage.remove() userStorage.remove()
api.setSecurityData(null) api.setSecurityData(null)
user.value = 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' import type AppLink from 'src/components/AppLink.vue'
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
// noinspection JSUnusedGlobalSymbols
export interface GlobalComponents { export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] 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) const date = new Date(dateString)
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
month: 'long', 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('&') 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 key: string
private readonly storageType: StorageType private readonly storageType: StorageType
constructor (key: string, storageType: StorageType = 'localStorage') { constructor(key: string, storageType: StorageType = 'localStorage') {
this.key = key this.key = key
this.storageType = storageType this.storageType = storageType
} }
get (): T | null { get(): T | null {
try { try {
const value = window[this.storageType].getItem(this.key) ?? '' const value = window[this.storageType].getItem(this.key) ?? ''
return JSON.parse(value) return JSON.parse(value) as T
} catch { }
catch {
return null return null
} }
} }
set (value: T): void { set(value: T): void {
const strValue = JSON.stringify(value) const strValue = JSON.stringify(value)
window[this.storageType].setItem(this.key, strValue) window[this.storageType].setItem(this.key, strValue)
} }
remove (): void { remove(): void {
window[this.storageType].removeItem(this.key) 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 AppLink from 'src/components/AppLink.vue'
import { routes } from 'src/router' import { routes } from 'src/router'
export const createTestRouter = (base?: string): Router => createRouter({ export function createTestRouter(base?: string): Router {
routes, return createRouter({
history: createMemoryHistory(base), routes,
}) history: createMemoryHistory(base),
})
}
interface RenderOptionsArgs { interface RenderOptionsArgs {
props: Record<string, unknown> props: Record<string, unknown>
@ -28,16 +30,16 @@ interface RenderOptionsArgs {
const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout const scheduler = typeof setImmediate === 'function' ? setImmediate : setTimeout
export function flushPromises (): Promise<void> { export function flushPromises(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
scheduler(resolve, 0) scheduler(resolve, 0)
}) })
} }
export function renderOptions (): RenderOptions export function renderOptions(): RenderOptions
export function renderOptions (args: Partial<Omit<RenderOptionsArgs, 'initialRoute'>>): RenderOptions export function renderOptions(args: Partial<Omit<RenderOptionsArgs, 'initialRoute'>>): RenderOptions
export async function renderOptions (args: (Partial<RenderOptionsArgs> & {initialRoute: RouteLocationRaw})): Promise<RenderOptions> export async function renderOptions(args: (Partial<RenderOptionsArgs> & { initialRoute: RouteLocationRaw })): Promise<RenderOptions>
export function renderOptions (args: Partial<RenderOptionsArgs> = {}): RenderOptions | Promise<RenderOptions> { export function renderOptions(args: Partial<RenderOptionsArgs> = {}): RenderOptions | Promise<RenderOptions> {
const router = args.router || createTestRouter() const router = args.router || createTestRouter()
const result = { const result = {
@ -52,28 +54,31 @@ export function renderOptions (args: Partial<RenderOptionsArgs> = {}): RenderOpt
...args.initialState, ...args.initialState,
}, },
stubActions: args.stubActions ?? false, stubActions: args.stubActions ?? false,
})], }),
],
components: { AppLink }, components: { AppLink },
}, },
} }
const { initialRoute } = args const { initialRoute } = args
if (!initialRoute) return result if (!initialRoute)
return result
return new Promise((resolve) => { 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({ return defineComponent({
render () { render() {
return h( return h(
'div', 'div',
{ id: 'root' }, { id: 'root' },
h(Suspense, null, { h(Suspense, null, {
default () { default() {
// eslint-disable-next-line ts/no-unsafe-argument
return h(component, props) return h(component, props)
}, },
fallback: h('div', 'Loading...'), 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 expectedRequestId = ''
let expectedRequest: Request let expectedRequest: Request
@ -98,11 +103,13 @@ async function waitForServerRequest (server: SetupServer, method: string, url: s
}) })
server.events.on('response:mocked', ({ requestId: reqId }) => { 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 }) => { 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() 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)] => { 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 (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[1] === 'number')
if (typeof args[2] === 'number') return [args[0], args[1], args[2], null] // [method, path, status, null] 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] return [args[0], args[1], 200, args[2]] // [method, path, 200, object]
} }
if (args.length === 2) { 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, args[1]]
} }
return ['all', args[0], 200, null] return ['all', args[0], 200, null]
@ -168,11 +179,11 @@ export function setupMockServer (...listeners: Listener[]) {
afterEach(() => void server.resetHandlers()) afterEach(() => void server.resetHandlers())
afterAll(() => void server.close()) afterAll(() => void server.close())
async function waitForRequest (path: string): Promise<Request> async function waitForRequest(path: string): Promise<Request>
async function waitForRequest (path: string, flush: boolean): 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): Promise<Request>
async function waitForRequest (method: HttpMethod, path: string, flush: boolean): 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(...args: [string] | [string, boolean] | [HttpMethod, string] | [HttpMethod, string, boolean]): Promise<Request> {
const [method, path, flush] = args.length === 1 const [method, path, flush] = args.length === 1
? ['all', args[0]] // ['all', path] ? ['all', args[0]] // ['all', path]
: args.length === 2 && typeof args[1] === 'boolean' : args.length === 2 && typeof args[1] === 'boolean'
@ -185,7 +196,7 @@ export function setupMockServer (...listeners: Listener[]) {
const originalUse = server.use.bind(server) const originalUse = server.use.bind(server)
function use (...listeners: Listener[]) { function use(...listeners: Listener[]) {
originalUse( originalUse(
...listeners.map((args) => { ...listeners.map((args) => {
let [method, path, status, response] = parseArgs(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>> 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 active: UseAsync<T>['active'] = ref(false)
const run: UseAsync<T>['run'] = async (...args) => { const run: UseAsync<T>['run'] = async (...args) => {
@ -17,14 +17,16 @@ export default function useAsync<T extends (...args: unknown[]) => unknown> (fn:
try { try {
const result = await fn(...args) const result = await fn(...args)
return result as ReturnType<T> return result as ReturnType<T>
} catch (error) { }
catch (error) {
if (isFetchError(error) && error.status === 401) { if (isFetchError(error) && error.status === 401) {
userStorage.remove() userStorage.remove()
await routerPush('login') await routerPush('login')
throw new Error('Unauthorized or token expired') throw new Error('Unauthorized or token expired')
} }
throw error throw error
} finally { }
finally {
active.value = false active.value = false
} }
} }

View File

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

View File

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

View File

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