Commit ebe451fd53a53b882ce5d053d90aca4014e89c61
1 parent
e7e5dcee
feat(usr): add tabsSlice for tab navigation state
Showing
3 changed files
with
128 additions
and
2 deletions
frontend/src/store/index.ts
| 1 | import { configureStore } from '@reduxjs/toolkit' | 1 | import { configureStore } from '@reduxjs/toolkit' |
| 2 | import authReducer from './slices/authSlice' | 2 | import authReducer from './slices/authSlice' |
| 3 | +import tabsReducer from './slices/tabsSlice' | ||
| 3 | 4 | ||
| 4 | export const store = configureStore({ | 5 | export const store = configureStore({ |
| 5 | reducer: { | 6 | reducer: { |
| 6 | - auth: authReducer | ||
| 7 | - } | 7 | + auth: authReducer, |
| 8 | + tabs: tabsReducer, | ||
| 9 | + }, | ||
| 8 | }) | 10 | }) |
| 9 | 11 | ||
| 10 | export type RootState = ReturnType<typeof store.getState> | 12 | export type RootState = ReturnType<typeof store.getState> |
frontend/src/store/slices/tabsSlice.ts
0 → 100644
| 1 | +import { createSlice, PayloadAction } from '@reduxjs/toolkit' | ||
| 2 | + | ||
| 3 | +export interface Tab { | ||
| 4 | + id: string | ||
| 5 | + title: string | ||
| 6 | + path: string | ||
| 7 | + closable: boolean | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +interface TabsState { | ||
| 11 | + tabs: Tab[] | ||
| 12 | + activeId: string | null | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +const initialState: TabsState = { | ||
| 16 | + tabs: [{ id: 'main', title: '主页', path: '/', closable: false }], | ||
| 17 | + activeId: 'main', | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +const tabsSlice = createSlice({ | ||
| 21 | + name: 'tabs', | ||
| 22 | + initialState, | ||
| 23 | + reducers: { | ||
| 24 | + openTab(state, action: PayloadAction<Tab>) { | ||
| 25 | + const existing = state.tabs.find(t => t.id === action.payload.id) | ||
| 26 | + if (existing) { | ||
| 27 | + existing.path = action.payload.path | ||
| 28 | + } else { | ||
| 29 | + state.tabs.push(action.payload) | ||
| 30 | + } | ||
| 31 | + state.activeId = action.payload.id | ||
| 32 | + }, | ||
| 33 | + closeTab(state, action: PayloadAction<string>) { | ||
| 34 | + const idx = state.tabs.findIndex(t => t.id === action.payload) | ||
| 35 | + if (idx === -1) return | ||
| 36 | + state.tabs.splice(idx, 1) | ||
| 37 | + if (state.activeId === action.payload) { | ||
| 38 | + const newIdx = Math.min(idx, state.tabs.length - 1) | ||
| 39 | + state.activeId = state.tabs[newIdx]?.id ?? null | ||
| 40 | + } | ||
| 41 | + }, | ||
| 42 | + activateTab(state, action: PayloadAction<string>) { | ||
| 43 | + state.activeId = action.payload | ||
| 44 | + }, | ||
| 45 | + updateTabPath(state, action: PayloadAction<{ id: string; path: string }>) { | ||
| 46 | + const tab = state.tabs.find(t => t.id === action.payload.id) | ||
| 47 | + if (tab) tab.path = action.payload.path | ||
| 48 | + }, | ||
| 49 | + }, | ||
| 50 | +}) | ||
| 51 | + | ||
| 52 | +export const { openTab, closeTab, activateTab, updateTabPath } = tabsSlice.actions | ||
| 53 | +export default tabsSlice.reducer |
frontend/src/test/tabsSlice.test.ts
0 → 100644
| 1 | +import { describe, it, expect } from 'vitest' | ||
| 2 | +import tabsReducer, { | ||
| 3 | + openTab, closeTab, activateTab, updateTabPath, | ||
| 4 | + type Tab | ||
| 5 | +} from '../store/slices/tabsSlice' | ||
| 6 | + | ||
| 7 | +const mainTab: Tab = { id: 'main', title: '主页', path: '/', closable: false } | ||
| 8 | +const listTab: Tab = { id: 'userlist', title: '用户列表', path: '/usr/users', closable: true } | ||
| 9 | +const detailTab: Tab = { id: 'userdetail', title: '用户信息单据', path: '/usr/users/new', closable: true } | ||
| 10 | + | ||
| 11 | +describe('tabsSlice', () => { | ||
| 12 | + it('initialState_hasMainTab', () => { | ||
| 13 | + const state = tabsReducer(undefined, { type: '' }) | ||
| 14 | + expect(state.tabs).toHaveLength(1) | ||
| 15 | + expect(state.tabs[0].id).toBe('main') | ||
| 16 | + expect(state.activeId).toBe('main') | ||
| 17 | + }) | ||
| 18 | + | ||
| 19 | + it('openTab_addsNewTabAndActivates', () => { | ||
| 20 | + const state = tabsReducer(undefined, openTab(listTab)) | ||
| 21 | + expect(state.tabs).toHaveLength(2) | ||
| 22 | + expect(state.tabs[1].id).toBe('userlist') | ||
| 23 | + expect(state.activeId).toBe('userlist') | ||
| 24 | + }) | ||
| 25 | + | ||
| 26 | + it('openTab_existingId_updatesPathAndActivates', () => { | ||
| 27 | + let state = tabsReducer(undefined, openTab(listTab)) | ||
| 28 | + const updated: Tab = { ...listTab, path: '/usr/users?queryValue=alice' } | ||
| 29 | + state = tabsReducer(state, openTab(updated)) | ||
| 30 | + expect(state.tabs).toHaveLength(2) | ||
| 31 | + expect(state.tabs[1].path).toBe('/usr/users?queryValue=alice') | ||
| 32 | + expect(state.activeId).toBe('userlist') | ||
| 33 | + }) | ||
| 34 | + | ||
| 35 | + it('closeTab_removesTabAndActivatesPrevious', () => { | ||
| 36 | + let state = tabsReducer(undefined, openTab(listTab)) | ||
| 37 | + state = tabsReducer(state, closeTab('userlist')) | ||
| 38 | + expect(state.tabs).toHaveLength(1) | ||
| 39 | + expect(state.activeId).toBe('main') | ||
| 40 | + }) | ||
| 41 | + | ||
| 42 | + it('closeTab_middleTab_activatesTabToLeft', () => { | ||
| 43 | + let state = tabsReducer(undefined, openTab(listTab)) | ||
| 44 | + state = tabsReducer(state, openTab(detailTab)) | ||
| 45 | + state = tabsReducer(state, closeTab('userdetail')) | ||
| 46 | + expect(state.activeId).toBe('userlist') | ||
| 47 | + }) | ||
| 48 | + | ||
| 49 | + it('closeTab_nonExistentId_doesNothing', () => { | ||
| 50 | + const state = tabsReducer(undefined, closeTab('nonexistent')) | ||
| 51 | + expect(state.tabs).toHaveLength(1) | ||
| 52 | + expect(state.activeId).toBe('main') | ||
| 53 | + }) | ||
| 54 | + | ||
| 55 | + it('activateTab_setsActiveId', () => { | ||
| 56 | + let state = tabsReducer(undefined, openTab(listTab)) | ||
| 57 | + state = tabsReducer(state, activateTab('main')) | ||
| 58 | + expect(state.activeId).toBe('main') | ||
| 59 | + }) | ||
| 60 | + | ||
| 61 | + it('updateTabPath_updatesPath', () => { | ||
| 62 | + let state = tabsReducer(undefined, openTab(listTab)) | ||
| 63 | + state = tabsReducer(state, updateTabPath({ id: 'userlist', path: '/usr/users?q=alice' })) | ||
| 64 | + expect(state.tabs[1].path).toBe('/usr/users?q=alice') | ||
| 65 | + }) | ||
| 66 | + | ||
| 67 | + it('updateTabPath_nonExistentId_doesNothing', () => { | ||
| 68 | + const state = tabsReducer(undefined, updateTabPath({ id: 'nope', path: '/x' })) | ||
| 69 | + expect(state.tabs[0].path).toBe('/') | ||
| 70 | + }) | ||
| 71 | +}) |