chore: migrate services and utils

This commit is contained in:
mutoe 2020-09-30 01:21:04 +08:00
parent d142e3f5b8
commit f87c41a053
20 changed files with 3339 additions and 42 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ dist
*.local
.idea
*.iml
cypress/videos

View File

@ -2,12 +2,17 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" />
<title>Conduit</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<link rel="icon" href="/favicon.ico" />
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="//demo.productionready.io/main.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -6,19 +6,27 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint --ext .ts,.vue src",
"test:e2e": "cypress run"
"test:unit": "jest",
"test:e2e": "cypress run",
"test": "yarn tsc && yarn lint && yarn test:unit && yarn test:e2e"
},
"dependencies": {
"vue": "^3.0.0"
"vue": "^3.0.0",
"vue-router": "^4.0.0-beta.12"
},
"devDependencies": {
"@types/jest": "^26.0.14",
"@typescript-eslint/eslint-plugin": "^4.3.0",
"@typescript-eslint/parser": "^4.2.0",
"@vue/compiler-sfc": "^3.0.0-rc.1",
"cypress": "^5.3.0",
"eslint": "^7.10.0",
"eslint-plugin-vue": "^7.0.0-beta.4",
"husky": "^4.3.0",
"jest": "^26.4.2",
"jsdom": "^16.4.0",
"lint-staged": "^10.4.0",
"ts-jest": "^26.4.1",
"typescript": "^4.0.3",
"vite": "^1.0.0-rc.1"
},
@ -34,10 +42,14 @@
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"sourceType": "module"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-recommended"
],
"rules": {
@ -48,7 +60,23 @@
"quotes": [
"error",
"single"
]
],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off"
}
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"testMatch": [
"<rootDir>/src/**/*.spec.ts"
],
"modulePaths": [
"<rootDir>"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setup-test.ts"
]
}
}

116
src/services.ts Normal file
View File

@ -0,0 +1,116 @@
import FetchRequest from './utils/request'
import parseStorageGet from './utils/parse-storage-get'
export const limit = 10
export const request = new FetchRequest({
prefix: `${process.env.API_HOST}/api`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${parseStorageGet('user')?.token}`,
},
})
export interface PostLoginForm {
email: string;
password: string;
}
export async function postLogin(form: PostLoginForm) {
return request.post<UserResponse>('/users/login', { user: form }).then(res => res.user)
}
interface PostRegisterForm extends PostLoginForm {
username: string;
}
export async function postRegister(form: PostRegisterForm) {
return request.post<UserResponse>('/users', { user: form }).then(res => res.user)
}
export async function getAllTags() {
return request.get<TagsResponse>('/tags').then(res => res.tags)
}
interface PostArticleForm {
title: string;
description: string;
body: string;
tagList: string[];
}
export async function postArticle(form: PostArticleForm) {
return request.post<ArticleResponse>('/articles', { article: form })
.then(res => res.article)
}
export async function getArticle(slug: string) {
return request.get<ArticleResponse>(`/articles/${slug}`).then(res => res.article)
}
export async function putArticle(slug: string, form: PostArticleForm) {
return request.put<ArticleResponse>(`/articles/${slug}`, { article: form })
.then(res => res.article)
}
export async function getArticles(page = 1) {
const params = { limit, offset: (page - 1) * limit }
return request.get<ArticlesResponse>('/articles', { params })
}
export async function getFeeds(page = 1) {
const params = { limit, offset: (page - 1) * limit }
return request.get<ArticlesResponse>('/articles/feed', { params })
}
export async function getArticlesByTag(tagName: string, page = 1) {
const params = { tag: tagName, limit, offset: (page - 1) * limit }
return request.get<ArticlesResponse>('/articles', { params })
}
export async function getProfileArticles(username: string, page = 1) {
const params = { limit, offset: (page - 1) * limit, author: username }
return request.get<ArticlesResponse>('/articles', { params })
}
export async function getFavoritedArticles(username: string, page = 1) {
const params = { limit, offset: (page - 1) * limit, favorited: username }
return request.get<ArticlesResponse>('/articles', { params })
}
export async function getCommentsByArticle(slug: string) {
return request.get<CommentsResponse>(`/articles/${slug}/comments`).then(res => res.comments)
}
export async function deleteComment(slug: string, commentId: number) {
return request.delete(`/articles/${slug}/comments/${commentId}`)
}
export async function postComment(slug: string, body: string) {
return request.post<CommentResponse>(`/articles/${slug}/comments`, { comment: { body } })
.then(res => res.comment)
}
export async function postFavoriteArticle(slug: string) {
return request.post<ArticleResponse>(`/articles/${slug}/favorite`).then(res => res.article)
}
export async function deleteFavoriteArticle(slug: string) {
return request.delete<ArticleResponse>(`/articles/${slug}/favorite`).then(res => res.article)
}
export async function getProfile(username: string) {
return request.get<ProfileResponse>(`/profiles/${username}`).then(res => res.profile)
}
export async function putProfile(form: Partial<Profile>) {
return request.put<ProfileResponse>('/user', form).then(res => res.profile)
}
export async function postFollowProfile(username: string) {
return request.post<ProfileResponse>(`/profiles/${username}/follow`).then(res => res.profile)
}
export async function deleteFollowProfile(username: string) {
return request.delete<ProfileResponse>(`/profiles/${username}/follow`).then(res => res.profile)
}

8
src/setup-test.ts Normal file
View File

@ -0,0 +1,8 @@
import 'jest'
jest.spyOn(window.Storage.prototype, 'getItem').mockReturnValue('')
jest.spyOn(window.Storage.prototype, 'setItem').mockImplementation()
afterEach(() => {
jest.clearAllMocks()
})

5
src/shimes-vue.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}

12
src/types/article.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
declare interface Article {
title: string;
slug: string;
body: string;
createdAt: string;
updatedAt: string;
tagList: string[];
description: string;
author: Profile;
favorited: boolean;
favoritesCount: number;
}

7
src/types/comment.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare interface ArticleComment {
id: number;
createdAt: string;
updatedAt: string;
body: string;
author: Profile;
}

0
src/types/global.d.ts vendored Normal file
View File

36
src/types/response.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
declare interface ResponseError {
[field: string]: string[];
}
declare interface Response {
errors: ResponseError;
}
declare interface UserResponse {
user: User;
}
declare interface TagsResponse {
tags: string[];
}
declare interface ProfileResponse {
profile: Profile;
}
declare interface ArticleResponse {
article: Article;
}
declare interface ArticlesResponse {
articles: Article[];
articlesCount: number;
}
declare interface CommentResponse {
comment: ArticleComment;
}
declare interface CommentsResponse {
comments: ArticleComment[];
}

20
src/types/store.d.ts vendored Normal file
View File

@ -0,0 +1,20 @@
interface RootState {
user: User | undefined;
errors: ResponseError;
}
type Action = ActionSetErrors | ActionCleanErrors | ActionUpdateUser | { type: '' }
interface ActionSetErrors {
type: 'SET_ERRORS';
errors: ResponseError;
}
interface ActionCleanErrors {
type: 'CLEAN_ERRORS';
}
interface ActionUpdateUser {
type: 'UPDATE_USER';
user?: Profile | User;
}

15
src/types/user.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
declare interface Profile {
username: string;
bio: string;
image: string;
following: boolean;
}
declare interface User {
id: number;
email: string;
username: string;
bio: string | null;
image: string | null;
token: string;
}

10
src/utils/filters.spec.ts Normal file
View File

@ -0,0 +1,10 @@
import { dateFilter } from 'src/utils/filters'
describe('# Date filters', function() {
it('should format date correctly', function() {
const dateString = '2019-01-01 00:00:00'
const result = dateFilter(dateString)
expect(result).toMatchInlineSnapshot('"January 1"')
})
})

7
src/utils/filters.ts Normal file
View File

@ -0,0 +1,7 @@
export const dateFilter = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
})
}

View File

@ -0,0 +1,24 @@
import parseStorageGet from 'src/utils/parse-storage-get'
describe('# parse storage get', function () {
it('should get an object given valid local storage item', function () {
jest.spyOn(global.localStorage, 'getItem')
.mockReturnValue(JSON.stringify({ foo: 'bar' }))
expect(() => {
const result = parseStorageGet('key')
expect(result).toMatchObject({ foo: 'bar' })
}).not.toThrow()
})
it('should get null given invalid storage item', function () {
jest.spyOn(global.localStorage, 'getItem')
.mockReturnValue('bar')
expect(() => {
const result = parseStorageGet('key')
expect(result).toBeNull()
}).not.toThrow()
})
})

View File

@ -0,0 +1,8 @@
export default function parseStorageGet (key: string) {
try {
const value = localStorage.getItem(key) || ''
return JSON.parse(value)
} catch (e) {
return null
}
}

210
src/utils/request.spec.ts Normal file
View File

@ -0,0 +1,210 @@
import FetchRequest from 'src/utils/request'
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json() {
return {}
},
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('# Request GET', function () {
it('should implement GET method', async function () {
const request = new FetchRequest()
await request.get('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'GET',
}))
})
it('should can set prefix of request url with global', async function () {
const request = new FetchRequest({ prefix: '/prefix' })
await request.get('/path')
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be set prefix of request url with single request', async function () {
const request = new FetchRequest()
await request.get('/path', { prefix: '/prefix' })
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('can be convert query object to query string in request url', async function () {
const request = new FetchRequest()
await request.get('/path', { params: { foo: 'bar' } })
expect(global.fetch).toBeCalledWith('/path?foo=bar', expect.any(Object))
})
it('should converted response body to json', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json() {
return {
foo: 'bar',
}
},
})
const request = new FetchRequest()
const response = await request.get('/path')
expect(response).toMatchObject({ foo: 'bar' })
})
it('should throw Error with response when request status code is not 2xx', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 400,
statusText: 'Bad request',
async json() {
return {}
},
})
const request = new FetchRequest()
await expect(request.post('/path')).rejects.toThrow('Bad request')
})
})
describe('# Request POST', function () {
it('should implement POST method', async function () {
const request = new FetchRequest()
await request.post('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'POST',
}))
})
it('should can set prefix of request url with global', async function () {
const request = new FetchRequest({ prefix: '/prefix' })
await request.post('/path')
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be set prefix of request url with single request', async function () {
const request = new FetchRequest()
await request.post('/path', {}, { prefix: '/prefix' })
expect(global.fetch).toBeCalledWith('/prefix/path', expect.any(Object))
})
it('should can be send json data in request body', async function () {
const request = new FetchRequest()
await request.post('/path', { foo: 'bar' })
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
body: JSON.stringify({ foo: 'bar' }),
}))
})
it('can be convert query object to query string in request url', async function () {
const request = new FetchRequest()
await request.post('/path', {}, { params: { foo: 'bar' } })
expect(global.fetch).toBeCalledWith('/path?foo=bar', expect.any(Object))
})
it('should converted response body to json', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
async json() {
return {
foo: 'bar',
}
},
})
const request = new FetchRequest()
const response = await request.post('/path')
expect(response).toMatchObject({ foo: 'bar' })
})
it('should throw Error with response when request status code is not 2xx', async function () {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 400,
statusText: 'Bad request',
async json() {
return {}
},
})
const request = new FetchRequest()
await expect(request.post('/path')).rejects.toThrow('Bad request')
})
it('should throw Error with 4xx code', function () {
global.fetch = jest.fn().mockReturnValue({
ok: true,
status: 422,
async json() {
return {
errors: {
some: [ 'error' ],
},
}
},
})
const request = new FetchRequest()
expect(() => {
request.get('/')
}).toThrow()
})
})
describe('# Request DELETE', function () {
it('should implement DELETE method', async function () {
const request = new FetchRequest()
await request.delete('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'DELETE',
}))
})
})
describe('# Request PUT', function () {
it('should implement PUT method', async function () {
const request = new FetchRequest()
await request.put('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'PUT',
}))
})
})
describe('# Request PATCH', function () {
it('should implement PATCH method', async function () {
const request = new FetchRequest()
await request.patch('/path')
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith('/path', expect.objectContaining({
method: 'PATCH',
}))
})
})

101
src/utils/request.ts Normal file
View File

@ -0,0 +1,101 @@
interface FetchRequestOptions {
prefix: string;
headers: Record<string, any>;
params: Record<string, any>;
responseInterceptor: (response: Response) => void;
}
export default class FetchRequest {
defaultOptions: FetchRequestOptions = {
prefix: '',
headers: {},
params: {},
responseInterceptor: (response) => void response,
}
public options: FetchRequestOptions
constructor(options: Partial<FetchRequestOptions> = {}) {
this.options = Object.assign({}, this.defaultOptions, options)
}
private generateFinalUrl = (url: string, options: Partial<FetchRequestOptions> = {}) => {
const prefix = options.prefix || this.options.prefix || ''
const params = options.params || {}
let finalUrl = `${prefix}${url}`
if (Object.keys(params).length) {
const queryString = Object.keys(params).map(key => `${key}=${params[key]}`).join('&')
finalUrl += `?${queryString}`
}
return finalUrl
}
private handleResponse = (response: Response) => {
this.options.responseInterceptor(response)
return response.json()
.then(json => {
if (response.status >= 200 && response.status < 300) {
return json
}
const error = new Error(response.statusText)
Object.assign(error, json, {
status: response.status,
statusText: response.statusText,
})
throw error
})
}
get<T = any>(url: string, options: Partial<FetchRequestOptions> = {}): Promise<T> {
const finalUrl = this.generateFinalUrl(url, options)
return fetch(finalUrl, {
method: 'GET',
headers: this.options.headers,
})
.then(this.handleResponse)
}
post<T = any>(url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
const finalUrl = this.generateFinalUrl(url, options)
return fetch(finalUrl, {
method: 'POST',
body: JSON.stringify(data),
headers: this.options.headers,
})
.then(this.handleResponse)
}
delete<T = any>(url: string, options: Partial<FetchRequestOptions> = {}): Promise<T> {
const finalUrl = this.generateFinalUrl(url, options)
return fetch(finalUrl, {
method: 'DELETE',
headers: this.options.headers,
})
.then(this.handleResponse)
}
put<T = any>(url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
const finalUrl = this.generateFinalUrl(url, options)
return fetch(finalUrl, {
method: 'PUT',
body: JSON.stringify(data),
headers: this.options.headers,
})
.then(this.handleResponse)
}
patch<T = any>(url: string, data: Record<string, any> = {}, options: Partial<FetchRequestOptions> = {}): Promise<T> {
const finalUrl = this.generateFinalUrl(url, options)
return fetch(finalUrl, {
method: 'PATCH',
body: JSON.stringify(data),
headers: this.options.headers,
})
.then(this.handleResponse)
}
}

View File

@ -63,5 +63,6 @@
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
},
"include": ["src"]
}

2749
yarn.lock

File diff suppressed because it is too large Load Diff