fix(history): require confirmation before clearing all history
This commit is contained in:
parent
819bc9d789
commit
a8983c583a
5 changed files with 441 additions and 13 deletions
|
|
@ -0,0 +1,94 @@
|
|||
// src/renderer/pages/history/ClearAllHistoryDialog.tsx
|
||||
// 히스토리 전체 삭제 확인 다이얼로그 — 삭제 건수, 다른 기기 전파, 필터 무시 여부를 알린다.
|
||||
|
||||
import React from 'react'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@mui/material'
|
||||
import { PhosphorText, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
export interface ClearAllHistoryDialogProps {
|
||||
open: boolean
|
||||
/** 필터와 무관한 전체 삭제 대상 건수 */
|
||||
count: number
|
||||
/** 화면이 태그·검색으로 좁혀져 있으면 '보이는 것만 지워지지 않는다'를 알린다 */
|
||||
filtered: boolean
|
||||
deleting: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ClearAllHistoryDialog({
|
||||
open,
|
||||
count,
|
||||
filtered,
|
||||
deleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ClearAllHistoryDialogProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={deleting ? undefined : onCancel}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
data-testid="clear-all-history-dialog"
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.inner,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<PhosphorText variant="label">{t('history.clearAll')}</PhosphorText>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<PhosphorText variant="body" component="p">
|
||||
{t('history.clearAllConfirm', { count })}
|
||||
</PhosphorText>
|
||||
{filtered && (
|
||||
<PhosphorText
|
||||
variant="body"
|
||||
component="p"
|
||||
data-testid="clear-all-history-filter-warning"
|
||||
sx={{ color: d3roPalette.status.warning }}
|
||||
>
|
||||
{t('history.clearAllIgnoresFilter')}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
data-testid="clear-all-history-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
autoFocus
|
||||
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
data-testid="clear-all-history-confirm"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
sx={{ height: 36, fontSize: d3roTypo.engrave.size, color: d3roPalette.status.danger }}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// src/renderer/pages/history/clear-all-history-flow.ts
|
||||
// 히스토리 '전체 삭제' 흐름 — 확인 단계를 거쳐야만 삭제가 실행되도록 강제하는 순수 상태 머신.
|
||||
//
|
||||
// 전체 삭제는 필터·검색과 무관하게 모든 기록을 지우고, 클라우드 동기화 중이면 다른 기기로도
|
||||
// 전파된다(HistoryService.deleteAll → sync.pushDelete). 되돌릴 수 없으므로 한 번의 클릭으로
|
||||
// 실행되면 안 된다. 이 모듈은 React·Electron에 의존하지 않으며 IO는 포트로 주입받는다.
|
||||
|
||||
/** 전체 삭제 흐름이 필요로 하는 IO 포트 (DIP: UI는 IPC 세부를 모른다) */
|
||||
export interface ClearAllHistoryPort {
|
||||
/** 필터와 무관한 전체 기록 수. 조회에 실패하면 null */
|
||||
countAll(): Promise<number | null>
|
||||
/** 전체 기록을 삭제한다. 성공 여부를 돌려준다 */
|
||||
deleteAll(): Promise<boolean>
|
||||
}
|
||||
|
||||
export type ClearAllHistoryState =
|
||||
| { readonly phase: 'idle' }
|
||||
| { readonly phase: 'counting' }
|
||||
| { readonly phase: 'confirming'; readonly count: number }
|
||||
| { readonly phase: 'deleting'; readonly count: number }
|
||||
|
||||
export type ClearAllHistoryListener = (state: ClearAllHistoryState) => void
|
||||
|
||||
export const CLEAR_ALL_IDLE: ClearAllHistoryState = { phase: 'idle' }
|
||||
|
||||
/** 현재 화면이 태그 필터나 검색으로 좁혀져 있는지 — 전체 삭제가 '보이는 것만'이 아님을 알려야 하는 경우 */
|
||||
export function isHistoryViewFiltered(activeTag: string | null, search: string): boolean {
|
||||
return activeTag !== null || search.trim().length > 0
|
||||
}
|
||||
|
||||
/** 확인 다이얼로그를 띄워야 하는 상태인지 */
|
||||
export function isClearAllDialogOpen(state: ClearAllHistoryState): boolean {
|
||||
return state.phase === 'confirming' || state.phase === 'deleting'
|
||||
}
|
||||
|
||||
/**
|
||||
* idle → (request) → counting → confirming(count) → (confirm) → deleting → idle
|
||||
* └──── (cancel) ────────────→ idle
|
||||
*
|
||||
* - deleteAll은 confirming 상태에서 confirm()이 호출될 때만, 정확히 한 번 실행된다.
|
||||
* - 삭제할 기록이 없거나 개수 조회에 실패하면 확인 단계로 가지 않고 idle로 돌아간다.
|
||||
* - 개수 조회 중에 취소되면 늦게 도착한 결과는 버린다.
|
||||
*/
|
||||
export class ClearAllHistoryFlow {
|
||||
private state: ClearAllHistoryState = CLEAR_ALL_IDLE
|
||||
private generation = 0
|
||||
|
||||
constructor(
|
||||
private readonly port: ClearAllHistoryPort,
|
||||
private readonly onChange: ClearAllHistoryListener = () => undefined,
|
||||
) {}
|
||||
|
||||
getState(): ClearAllHistoryState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/** 삭제 요청 — 실제 삭제는 하지 않고 확인 단계로만 이동한다 */
|
||||
async request(): Promise<void> {
|
||||
if (this.state.phase !== 'idle') return
|
||||
const generation = ++this.generation
|
||||
this.set({ phase: 'counting' })
|
||||
|
||||
let count: number | null
|
||||
try {
|
||||
count = await this.port.countAll()
|
||||
} catch {
|
||||
count = null
|
||||
}
|
||||
|
||||
if (generation !== this.generation) return
|
||||
if (count === null || count <= 0) {
|
||||
this.set(CLEAR_ALL_IDLE)
|
||||
return
|
||||
}
|
||||
this.set({ phase: 'confirming', count })
|
||||
}
|
||||
|
||||
/** 확인 단계를 닫는다. 삭제 진행 중에는 취소할 수 없다 */
|
||||
cancel(): void {
|
||||
if (this.state.phase !== 'counting' && this.state.phase !== 'confirming') return
|
||||
this.generation++
|
||||
this.set(CLEAR_ALL_IDLE)
|
||||
}
|
||||
|
||||
/** 사용자가 확인했을 때만 삭제를 실행한다. 삭제가 실행되어 성공하면 true */
|
||||
async confirm(): Promise<boolean> {
|
||||
if (this.state.phase !== 'confirming') return false
|
||||
this.set({ phase: 'deleting', count: this.state.count })
|
||||
try {
|
||||
return await this.port.deleteAll()
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
this.generation++
|
||||
this.set(CLEAR_ALL_IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
private set(next: ClearAllHistoryState): void {
|
||||
this.state = next
|
||||
this.onChange(next)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// src/renderer/pages/history/useClearAllHistory.ts
|
||||
// ClearAllHistoryFlow를 React 상태에 연결하고, Electron IPC를 포트로 어댑팅한다.
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
CLEAR_ALL_IDLE,
|
||||
ClearAllHistoryFlow,
|
||||
type ClearAllHistoryPort,
|
||||
type ClearAllHistoryState,
|
||||
} from './clear-all-history-flow'
|
||||
|
||||
/** preload history API → ClearAllHistoryPort 어댑터 */
|
||||
export function createElectronClearAllHistoryPort(): ClearAllHistoryPort {
|
||||
return {
|
||||
async countAll() {
|
||||
const result = await window.electronAPI.history.getAll({ page: 0, pageSize: 1, sortOrder: 'desc' })
|
||||
return result.success ? result.data.total : null
|
||||
},
|
||||
async deleteAll() {
|
||||
const result = await window.electronAPI.history.deleteAll()
|
||||
return result.success
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClearAllHistoryControls {
|
||||
state: ClearAllHistoryState
|
||||
request: () => void
|
||||
confirm: () => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onSettled 삭제가 실행된 뒤(성공·실패 무관) 목록을 다시 불러오기 위한 콜백
|
||||
* @param port 테스트·다른 호스트용 포트 주입 지점. 기본값은 Electron IPC
|
||||
*/
|
||||
export function useClearAllHistory(
|
||||
onSettled: () => void,
|
||||
port?: ClearAllHistoryPort,
|
||||
): ClearAllHistoryControls {
|
||||
const [state, setState] = useState<ClearAllHistoryState>(CLEAR_ALL_IDLE)
|
||||
const onSettledRef = useRef(onSettled)
|
||||
onSettledRef.current = onSettled
|
||||
|
||||
const flowRef = useRef<ClearAllHistoryFlow | null>(null)
|
||||
if (flowRef.current === null) {
|
||||
flowRef.current = new ClearAllHistoryFlow(port ?? createElectronClearAllHistoryPort(), setState)
|
||||
}
|
||||
const flow = flowRef.current
|
||||
|
||||
const request = useCallback(() => {
|
||||
void flow.request()
|
||||
}, [flow])
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
if (flow.getState().phase !== 'confirming') return
|
||||
void flow.confirm().finally(() => onSettledRef.current())
|
||||
}, [flow])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
flow.cancel()
|
||||
}, [flow])
|
||||
|
||||
return { state, request, confirm, cancel }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue