feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -5,7 +5,11 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -18,7 +22,10 @@ function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
|
||||
}
|
||||
|
||||
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
|
||||
async function expectApiError(
|
||||
call: Promise<unknown>,
|
||||
matcher: Record<string, unknown>,
|
||||
) {
|
||||
await expect(call).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(call).rejects.toMatchObject(matcher)
|
||||
}
|
||||
@@ -102,7 +109,9 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending'),
|
||||
)
|
||||
expect(session.session).toEqual({ verified: false })
|
||||
})
|
||||
|
||||
@@ -150,7 +159,9 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
})
|
||||
|
||||
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
|
||||
fetchMock.mockResolvedValue(
|
||||
mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
)
|
||||
|
||||
const call = useApi().request('/v1/auth/login', { method: 'POST' })
|
||||
|
||||
@@ -160,7 +171,9 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(429))
|
||||
|
||||
const call = useApi().request('/v1/auth/password-reset', { method: 'POST' })
|
||||
const call = useApi().request('/v1/auth/password-reset', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
await expectApiError(call, { status: 429, retryAfter: undefined })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import {
|
||||
useArticleDraft,
|
||||
readMirroredDraft,
|
||||
} from '@/composables/useArticleDraft'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Loading an article without a mirrored draft starts clean', () => {
|
||||
it('sets the body and clean state from the given article', () => {
|
||||
setActivePinia(createPinia())
|
||||
const draft = useArticleDraft(12)
|
||||
|
||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||
|
||||
expect(draft.body.value).toBe('<p>Original</p>')
|
||||
expect(draft.state.value).toBe('clean')
|
||||
expect(draft.savedAt.value).toEqual(new Date('2026-09-26T11:02:00Z'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading an article with a mirrored draft restores it as dirty', () => {
|
||||
it('prefers the sessionStorage body over the loaded one', () => {
|
||||
setActivePinia(createPinia())
|
||||
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
|
||||
const draft = useArticleDraft(12)
|
||||
|
||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||
|
||||
expect(draft.body.value).toBe('<p>Restored</p>')
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editing moves the state to dirty', () => {
|
||||
it('updates the body and sets state to dirty', () => {
|
||||
setActivePinia(createPinia())
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
expect(draft.body.value).toBe('<p>Changed</p>')
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful save sets state saved and records the time', () => {
|
||||
it('resolves "saved" and clears any mirrored draft', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
|
||||
)
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('saved')
|
||||
expect(draft.state.value).toBe('saved')
|
||||
expect(draft.savedAt.value).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 500 response sets state error and keeps the body', () => {
|
||||
it('resolves "error" without touching the body', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('error')
|
||||
expect(draft.state.value).toBe('error')
|
||||
expect(draft.body.value).toBe('<p>Changed</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 404 response keeps the body and does not enter the error state', () => {
|
||||
it('resolves "not-found" and stays dirty', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Changed</p>')
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('not-found')
|
||||
expect(draft.state.value).toBe('dirty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A 401 response mirrors the body to sessionStorage', () => {
|
||||
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
|
||||
const draft = useArticleDraft(12)
|
||||
draft.load('<p>Original</p>', new Date())
|
||||
draft.edit('<p>Paragraph added just before the session expired.</p>')
|
||||
|
||||
const outcome = await draft.save()
|
||||
|
||||
expect(outcome).toBe('unauthorized')
|
||||
expect(readMirroredDraft(12)).toBe(
|
||||
'<p>Paragraph added just before the session expired.</p>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,7 @@ export interface RequestOptions extends RequestInit {
|
||||
}
|
||||
|
||||
export const isOffline = ref(false)
|
||||
export const authRedirectPending = ref(false)
|
||||
|
||||
function isNavigatorOnLine() {
|
||||
return typeof navigator === 'undefined' || navigator.onLine !== false
|
||||
@@ -60,8 +61,15 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
onUnauthorized = 'redirect',
|
||||
onForbidden = 'redirect',
|
||||
...init
|
||||
} = options
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -77,13 +85,24 @@ export function useApi() {
|
||||
if (response.status === 401 && onUnauthorized === 'redirect') {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } })
|
||||
authRedirectPending.value = true
|
||||
router
|
||||
.push({
|
||||
path: '/login',
|
||||
query: { redirect: currentPath, reason: 'expired' },
|
||||
})
|
||||
.finally(() => {
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
throw new ApiError(401)
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const session = useSessionStore()
|
||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||
if (
|
||||
onForbidden !== 'passthrough' &&
|
||||
session.session?.verified === false
|
||||
) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
throw new ApiError(403)
|
||||
@@ -100,9 +119,12 @@ export function useApi() {
|
||||
}
|
||||
|
||||
async function getMe(): Promise<MeResult> {
|
||||
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
|
||||
onUnauthorized: 'passthrough',
|
||||
})
|
||||
const body = await request<{ is_verified: boolean; username?: string }>(
|
||||
'/v1/auth/me',
|
||||
{
|
||||
onUnauthorized: 'passthrough',
|
||||
},
|
||||
)
|
||||
return { verified: Boolean(body.is_verified), username: body.username }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
export type ArticleDraftBusinessState =
|
||||
| 'clean'
|
||||
| 'dirty'
|
||||
| 'saving'
|
||||
| 'saved'
|
||||
| 'error'
|
||||
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
||||
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
|
||||
|
||||
function draftStorageKey(articleId: number) {
|
||||
return `renewcms:article-draft:${articleId}`
|
||||
}
|
||||
|
||||
export function readMirroredDraft(articleId: number): string | null {
|
||||
return sessionStorage.getItem(draftStorageKey(articleId))
|
||||
}
|
||||
|
||||
export function useArticleDraft(articleId: number) {
|
||||
const { request } = useApi()
|
||||
|
||||
const body = ref('')
|
||||
const businessState = ref<ArticleDraftBusinessState>('clean')
|
||||
const savedAt = ref<Date | null>(null)
|
||||
const offline = ref(
|
||||
typeof navigator === 'undefined' ? false : !navigator.onLine,
|
||||
)
|
||||
|
||||
function handleOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
offline.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
const state = computed<ArticleDraftState>(() =>
|
||||
offline.value ? 'offline' : businessState.value,
|
||||
)
|
||||
|
||||
function mirrorToStorage() {
|
||||
sessionStorage.setItem(draftStorageKey(articleId), body.value)
|
||||
}
|
||||
|
||||
function clearStorage() {
|
||||
sessionStorage.removeItem(draftStorageKey(articleId))
|
||||
}
|
||||
|
||||
function load(initialBody: string, initialSavedAt: Date) {
|
||||
const mirrored = readMirroredDraft(articleId)
|
||||
savedAt.value = initialSavedAt
|
||||
if (mirrored !== null) {
|
||||
body.value = mirrored
|
||||
businessState.value = 'dirty'
|
||||
return
|
||||
}
|
||||
body.value = initialBody
|
||||
businessState.value = 'clean'
|
||||
}
|
||||
|
||||
function edit(newBody: string) {
|
||||
body.value = newBody
|
||||
businessState.value = 'dirty'
|
||||
}
|
||||
|
||||
async function save(): Promise<SaveOutcome> {
|
||||
businessState.value = 'saving'
|
||||
try {
|
||||
await request<Article>(`/v1/article/${articleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: body.value }),
|
||||
})
|
||||
businessState.value = 'saved'
|
||||
savedAt.value = new Date()
|
||||
clearStorage()
|
||||
return 'saved'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
businessState.value = 'dirty'
|
||||
return 'not-found'
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
mirrorToStorage()
|
||||
businessState.value = 'dirty'
|
||||
return 'unauthorized'
|
||||
}
|
||||
businessState.value = 'error'
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
return { body, state, savedAt, load, edit, save }
|
||||
}
|
||||
@@ -7,7 +7,9 @@ export function useColorMode() {
|
||||
|
||||
return {
|
||||
get preference(): ColorModePreference {
|
||||
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
|
||||
return store.value === 'auto'
|
||||
? 'system'
|
||||
: (store.value as ColorModePreference)
|
||||
},
|
||||
set preference(value: ColorModePreference) {
|
||||
store.value = value === 'system' ? 'auto' : value
|
||||
|
||||
Reference in new issue
Block a user