mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat(redux): create email state slices package
This commit is contained in:
37
redux/email/package.json
Normal file
37
redux/email/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@metabuilder/redux-email",
|
||||
"version": "1.0.0",
|
||||
"description": "Redux slices for email client state management (lists, details, compose, filters)",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --declaration --emitDeclarationOnly",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"redux",
|
||||
"email",
|
||||
"client",
|
||||
"state-management",
|
||||
"slices",
|
||||
"toolkit"
|
||||
],
|
||||
"author": "MetaBuilder",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-redux": "^8.0.0 || ^9.0.0",
|
||||
"@reduxjs/toolkit": "^1.9.7 || ^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0 || ^19.0.0",
|
||||
"@reduxjs/toolkit": "^2.5.2",
|
||||
"react-redux": "^9.1.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
]
|
||||
}
|
||||
29
redux/email/src/index.ts
Normal file
29
redux/email/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @metabuilder/redux-email
|
||||
*
|
||||
* Redux state management package for email client functionality.
|
||||
* Provides slices for email lists, details, composition, and filtering.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* import { configureStore } from '@reduxjs/toolkit'
|
||||
* import {
|
||||
* emailListSlice,
|
||||
* emailDetailSlice,
|
||||
* emailComposeSlice,
|
||||
* emailFiltersSlice
|
||||
* } from '@metabuilder/redux-email'
|
||||
*
|
||||
* const store = configureStore({
|
||||
* reducer: {
|
||||
* emailList: emailListSlice.reducer,
|
||||
* emailDetail: emailDetailSlice.reducer,
|
||||
* emailCompose: emailComposeSlice.reducer,
|
||||
* emailFilters: emailFiltersSlice.reducer,
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Re-export all slices and their types
|
||||
export * from './slices'
|
||||
363
redux/email/src/slices/emailComposeSlice.ts
Normal file
363
redux/email/src/slices/emailComposeSlice.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Redux Slice for Email Compose/Draft State Management
|
||||
* Handles draft creation, editing, and storage
|
||||
*/
|
||||
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface EmailDraft {
|
||||
id: string
|
||||
to: string[]
|
||||
cc: string[]
|
||||
bcc: string[]
|
||||
subject: string
|
||||
textBody: string
|
||||
htmlBody?: string
|
||||
isDraft: boolean
|
||||
isSending: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
inReplyTo?: string
|
||||
attachmentIds?: string[]
|
||||
}
|
||||
|
||||
export interface ComposeDraftState {
|
||||
currentDraft: EmailDraft | null
|
||||
drafts: EmailDraft[]
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
error: string | null
|
||||
successMessage: string | null
|
||||
}
|
||||
|
||||
const initialState: ComposeDraftState = {
|
||||
currentDraft: null,
|
||||
drafts: [],
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
successMessage: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Async thunk to save draft to server
|
||||
*/
|
||||
export const saveDraftAsync = createAsyncThunk<
|
||||
EmailDraft,
|
||||
EmailDraft,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailCompose/saveDraftAsync',
|
||||
async (draft, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/email/drafts', {
|
||||
method: draft.id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(draft)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save draft')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Async thunk to send email
|
||||
*/
|
||||
export const sendEmailAsync = createAsyncThunk<
|
||||
{ success: boolean; messageId: string },
|
||||
EmailDraft,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailCompose/sendEmailAsync',
|
||||
async (draft, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/email/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...draft,
|
||||
isDraft: false,
|
||||
isSending: true
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send email')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Async thunk to fetch drafts
|
||||
*/
|
||||
export const fetchDrafts = createAsyncThunk<
|
||||
EmailDraft[],
|
||||
void,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailCompose/fetchDrafts',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/email/drafts')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch drafts')
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const emailComposeSlice = createSlice({
|
||||
name: 'emailCompose',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Create new draft
|
||||
createDraft: (state) => {
|
||||
const newDraft: EmailDraft = {
|
||||
id: `draft_${Date.now()}`,
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '',
|
||||
textBody: '',
|
||||
isDraft: true,
|
||||
isSending: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
state.currentDraft = newDraft
|
||||
state.drafts.push(newDraft)
|
||||
},
|
||||
|
||||
// Update draft field
|
||||
updateDraft: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
field: keyof EmailDraft
|
||||
value: unknown
|
||||
}>
|
||||
) => {
|
||||
if (state.currentDraft) {
|
||||
const { field, value } = action.payload
|
||||
;(state.currentDraft as Record<string, unknown>)[field] = value
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
// Update multiple draft fields
|
||||
updateDraftMultiple: (
|
||||
state,
|
||||
action: PayloadAction<Partial<EmailDraft>>
|
||||
) => {
|
||||
if (state.currentDraft) {
|
||||
Object.assign(state.currentDraft, action.payload)
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
// Add recipient (to, cc, bcc)
|
||||
addRecipient: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
type: 'to' | 'cc' | 'bcc'
|
||||
email: string
|
||||
}>
|
||||
) => {
|
||||
if (state.currentDraft) {
|
||||
const { type, email } = action.payload
|
||||
if (!state.currentDraft[type].includes(email)) {
|
||||
state.currentDraft[type].push(email)
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Remove recipient
|
||||
removeRecipient: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
type: 'to' | 'cc' | 'bcc'
|
||||
email: string
|
||||
}>
|
||||
) => {
|
||||
if (state.currentDraft) {
|
||||
const { type, email } = action.payload
|
||||
state.currentDraft[type] = state.currentDraft[type].filter((e) => e !== email)
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
// Add attachment
|
||||
addAttachment: (state, action: PayloadAction<string>) => {
|
||||
if (state.currentDraft) {
|
||||
if (!state.currentDraft.attachmentIds) {
|
||||
state.currentDraft.attachmentIds = []
|
||||
}
|
||||
if (!state.currentDraft.attachmentIds.includes(action.payload)) {
|
||||
state.currentDraft.attachmentIds.push(action.payload)
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Remove attachment
|
||||
removeAttachment: (state, action: PayloadAction<string>) => {
|
||||
if (state.currentDraft) {
|
||||
state.currentDraft.attachmentIds = (
|
||||
state.currentDraft.attachmentIds || []
|
||||
).filter((id) => id !== action.payload)
|
||||
state.currentDraft.updatedAt = Date.now()
|
||||
}
|
||||
},
|
||||
|
||||
// Set current draft
|
||||
setCurrentDraft: (state, action: PayloadAction<EmailDraft | null>) => {
|
||||
state.currentDraft = action.payload
|
||||
},
|
||||
|
||||
// Clear current draft
|
||||
clearDraft: (state) => {
|
||||
state.currentDraft = null
|
||||
state.error = null
|
||||
state.successMessage = null
|
||||
},
|
||||
|
||||
// Delete draft
|
||||
deleteDraft: (state, action: PayloadAction<string>) => {
|
||||
state.drafts = state.drafts.filter((d) => d.id !== action.payload)
|
||||
if (state.currentDraft?.id === action.payload) {
|
||||
state.currentDraft = null
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: (state) => {
|
||||
state.error = null
|
||||
},
|
||||
|
||||
// Clear success message
|
||||
clearSuccessMessage: (state) => {
|
||||
state.successMessage = null
|
||||
}
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Save draft - pending
|
||||
.addCase(saveDraftAsync.pending, (state) => {
|
||||
state.isSaving = true
|
||||
state.error = null
|
||||
})
|
||||
// Save draft - fulfilled
|
||||
.addCase(saveDraftAsync.fulfilled, (state, action) => {
|
||||
state.isSaving = false
|
||||
state.currentDraft = action.payload
|
||||
state.drafts = state.drafts.map((d) =>
|
||||
d.id === action.payload.id ? action.payload : d
|
||||
)
|
||||
state.successMessage = 'Draft saved successfully'
|
||||
})
|
||||
// Save draft - rejected
|
||||
.addCase(saveDraftAsync.rejected, (state, action) => {
|
||||
state.isSaving = false
|
||||
state.error = action.payload || 'Failed to save draft'
|
||||
})
|
||||
// Send email - pending
|
||||
.addCase(sendEmailAsync.pending, (state) => {
|
||||
state.isLoading = true
|
||||
state.error = null
|
||||
if (state.currentDraft) {
|
||||
state.currentDraft.isSending = true
|
||||
}
|
||||
})
|
||||
// Send email - fulfilled
|
||||
.addCase(sendEmailAsync.fulfilled, (state) => {
|
||||
state.isLoading = false
|
||||
state.currentDraft = null
|
||||
state.successMessage = 'Email sent successfully'
|
||||
})
|
||||
// Send email - rejected
|
||||
.addCase(sendEmailAsync.rejected, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.error = action.payload || 'Failed to send email'
|
||||
if (state.currentDraft) {
|
||||
state.currentDraft.isSending = false
|
||||
}
|
||||
})
|
||||
// Fetch drafts - pending
|
||||
.addCase(fetchDrafts.pending, (state) => {
|
||||
state.isLoading = true
|
||||
state.error = null
|
||||
})
|
||||
// Fetch drafts - fulfilled
|
||||
.addCase(fetchDrafts.fulfilled, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.drafts = action.payload
|
||||
})
|
||||
// Fetch drafts - rejected
|
||||
.addCase(fetchDrafts.rejected, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.error = action.payload || 'Failed to fetch drafts'
|
||||
state.drafts = []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
createDraft,
|
||||
updateDraft,
|
||||
updateDraftMultiple,
|
||||
addRecipient,
|
||||
removeRecipient,
|
||||
addAttachment,
|
||||
removeAttachment,
|
||||
setCurrentDraft,
|
||||
clearDraft,
|
||||
deleteDraft,
|
||||
clearError,
|
||||
clearSuccessMessage
|
||||
} = emailComposeSlice.actions
|
||||
|
||||
// Selectors
|
||||
export const selectCurrentDraft = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.currentDraft
|
||||
|
||||
export const selectDrafts = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.drafts
|
||||
|
||||
export const selectIsLoading = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.isLoading
|
||||
|
||||
export const selectIsSaving = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.isSaving
|
||||
|
||||
export const selectError = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.error
|
||||
|
||||
export const selectSuccessMessage = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.successMessage
|
||||
|
||||
export const selectDraftCount = (state: { emailCompose: ComposeDraftState }) =>
|
||||
state.emailCompose.drafts.length
|
||||
|
||||
export default emailComposeSlice.reducer
|
||||
205
redux/email/src/slices/emailDetailSlice.ts
Normal file
205
redux/email/src/slices/emailDetailSlice.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Redux Slice for Email Detail/Thread View State Management
|
||||
* Handles selected email details and conversation threads
|
||||
*/
|
||||
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface EmailDetail {
|
||||
id: string
|
||||
from: string
|
||||
to: string[]
|
||||
cc?: string[]
|
||||
bcc?: string[]
|
||||
subject: string
|
||||
textBody?: string
|
||||
htmlBody?: string
|
||||
receivedAt: number
|
||||
isRead: boolean
|
||||
isStarred: boolean
|
||||
attachments?: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
downloadUrl?: string
|
||||
}>
|
||||
conversationId?: string
|
||||
replyTo?: string
|
||||
}
|
||||
|
||||
export interface ThreadMessage extends EmailDetail {
|
||||
isReply: boolean
|
||||
}
|
||||
|
||||
export interface EmailDetailState {
|
||||
selectedEmail: EmailDetail | null
|
||||
threadMessages: ThreadMessage[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: EmailDetailState = {
|
||||
selectedEmail: null,
|
||||
threadMessages: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Async thunk to fetch email details
|
||||
*/
|
||||
export const fetchEmailDetail = createAsyncThunk<
|
||||
EmailDetail,
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailDetail/fetchEmailDetail',
|
||||
async (messageId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/email/messages/${messageId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch email details')
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Async thunk to fetch conversation thread
|
||||
*/
|
||||
export const fetchConversationThread = createAsyncThunk<
|
||||
ThreadMessage[],
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailDetail/fetchConversationThread',
|
||||
async (conversationId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/email/conversations/${conversationId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch conversation thread')
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const emailDetailSlice = createSlice({
|
||||
name: 'emailDetail',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Set selected email
|
||||
setSelectedEmail: (state, action: PayloadAction<EmailDetail | null>) => {
|
||||
state.selectedEmail = action.payload
|
||||
},
|
||||
|
||||
// Add message to thread
|
||||
addToThread: (state, action: PayloadAction<ThreadMessage>) => {
|
||||
state.threadMessages.push(action.payload)
|
||||
},
|
||||
|
||||
// Clear thread
|
||||
clearThread: (state) => {
|
||||
state.threadMessages = []
|
||||
},
|
||||
|
||||
// Update email read status
|
||||
setEmailRead: (state, action: PayloadAction<boolean>) => {
|
||||
if (state.selectedEmail) {
|
||||
state.selectedEmail.isRead = action.payload
|
||||
}
|
||||
},
|
||||
|
||||
// Update email starred status
|
||||
setEmailStarred: (state, action: PayloadAction<boolean>) => {
|
||||
if (state.selectedEmail) {
|
||||
state.selectedEmail.isStarred = action.payload
|
||||
}
|
||||
},
|
||||
|
||||
// Clear selected email and thread
|
||||
clearEmailDetail: (state) => {
|
||||
state.selectedEmail = null
|
||||
state.threadMessages = []
|
||||
},
|
||||
|
||||
// Error handling
|
||||
clearError: (state) => {
|
||||
state.error = null
|
||||
}
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Fetch email detail - pending
|
||||
.addCase(fetchEmailDetail.pending, (state) => {
|
||||
state.isLoading = true
|
||||
state.error = null
|
||||
})
|
||||
// Fetch email detail - fulfilled
|
||||
.addCase(fetchEmailDetail.fulfilled, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.selectedEmail = action.payload
|
||||
})
|
||||
// Fetch email detail - rejected
|
||||
.addCase(fetchEmailDetail.rejected, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.error = action.payload || 'Failed to fetch email details'
|
||||
state.selectedEmail = null
|
||||
})
|
||||
// Fetch conversation thread - pending
|
||||
.addCase(fetchConversationThread.pending, (state) => {
|
||||
state.isLoading = true
|
||||
state.error = null
|
||||
})
|
||||
// Fetch conversation thread - fulfilled
|
||||
.addCase(fetchConversationThread.fulfilled, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.threadMessages = action.payload
|
||||
})
|
||||
// Fetch conversation thread - rejected
|
||||
.addCase(fetchConversationThread.rejected, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.error = action.payload || 'Failed to fetch conversation thread'
|
||||
state.threadMessages = []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
setSelectedEmail,
|
||||
addToThread,
|
||||
clearThread,
|
||||
setEmailRead,
|
||||
setEmailStarred,
|
||||
clearEmailDetail,
|
||||
clearError
|
||||
} = emailDetailSlice.actions
|
||||
|
||||
// Selectors
|
||||
export const selectSelectedEmail = (state: { emailDetail: EmailDetailState }) =>
|
||||
state.emailDetail.selectedEmail
|
||||
|
||||
export const selectThreadMessages = (state: { emailDetail: EmailDetailState }) =>
|
||||
state.emailDetail.threadMessages
|
||||
|
||||
export const selectIsLoading = (state: { emailDetail: EmailDetailState }) =>
|
||||
state.emailDetail.isLoading
|
||||
|
||||
export const selectError = (state: { emailDetail: EmailDetailState }) =>
|
||||
state.emailDetail.error
|
||||
|
||||
export const selectConversationThread = (state: { emailDetail: EmailDetailState }) =>
|
||||
state.emailDetail.threadMessages
|
||||
|
||||
export default emailDetailSlice.reducer
|
||||
197
redux/email/src/slices/emailFiltersSlice.ts
Normal file
197
redux/email/src/slices/emailFiltersSlice.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Redux Slice for Email Filters State Management
|
||||
* Handles saved filters and search query management
|
||||
*/
|
||||
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface EmailFilter {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
criteria: {
|
||||
from?: string
|
||||
to?: string
|
||||
subject?: string
|
||||
hasAttachments?: boolean
|
||||
isRead?: boolean
|
||||
isStarred?: boolean
|
||||
dateFrom?: number
|
||||
dateTo?: number
|
||||
sizeMin?: number
|
||||
sizeMax?: number
|
||||
}
|
||||
color?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
id: string
|
||||
text: string
|
||||
timestamp: number
|
||||
frequency: number
|
||||
}
|
||||
|
||||
export interface EmailFilterState {
|
||||
savedFilters: EmailFilter[]
|
||||
recentSearches: SearchQuery[]
|
||||
activeFilterId: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: EmailFilterState = {
|
||||
savedFilters: [],
|
||||
recentSearches: [],
|
||||
activeFilterId: null,
|
||||
isLoading: false,
|
||||
error: null
|
||||
}
|
||||
|
||||
export const emailFiltersSlice = createSlice({
|
||||
name: 'emailFilters',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Add new saved filter
|
||||
addFilter: (state, action: PayloadAction<EmailFilter>) => {
|
||||
const newFilter = {
|
||||
...action.payload,
|
||||
id: action.payload.id || `filter_${Date.now()}`,
|
||||
createdAt: action.payload.createdAt || Date.now(),
|
||||
updatedAt: action.payload.updatedAt || Date.now()
|
||||
}
|
||||
state.savedFilters.push(newFilter)
|
||||
},
|
||||
|
||||
// Update existing filter
|
||||
updateFilter: (state, action: PayloadAction<EmailFilter>) => {
|
||||
const index = state.savedFilters.findIndex((f) => f.id === action.payload.id)
|
||||
if (index !== -1) {
|
||||
state.savedFilters[index] = {
|
||||
...action.payload,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Delete filter
|
||||
removeFilter: (state, action: PayloadAction<string>) => {
|
||||
state.savedFilters = state.savedFilters.filter((f) => f.id !== action.payload)
|
||||
if (state.activeFilterId === action.payload) {
|
||||
state.activeFilterId = null
|
||||
}
|
||||
},
|
||||
|
||||
// Delete all filters
|
||||
clearAllFilters: (state) => {
|
||||
state.savedFilters = []
|
||||
state.activeFilterId = null
|
||||
},
|
||||
|
||||
// Set active filter
|
||||
setActiveFilter: (state, action: PayloadAction<string | null>) => {
|
||||
state.activeFilterId = action.payload
|
||||
},
|
||||
|
||||
// Add to recent searches
|
||||
addSearchQuery: (state, action: PayloadAction<string>) => {
|
||||
const existing = state.recentSearches.find((s) => s.text === action.payload)
|
||||
|
||||
if (existing) {
|
||||
// Update frequency if search already exists
|
||||
existing.frequency += 1
|
||||
existing.timestamp = Date.now()
|
||||
} else {
|
||||
// Add new search to beginning of list
|
||||
state.recentSearches.unshift({
|
||||
id: `search_${Date.now()}`,
|
||||
text: action.payload,
|
||||
timestamp: Date.now(),
|
||||
frequency: 1
|
||||
})
|
||||
|
||||
// Keep only last 20 searches
|
||||
if (state.recentSearches.length > 20) {
|
||||
state.recentSearches.pop()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Remove search from history
|
||||
removeSearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.recentSearches = state.recentSearches.filter((s) => s.id !== action.payload)
|
||||
},
|
||||
|
||||
// Clear search history
|
||||
clearSearchHistory: (state) => {
|
||||
state.recentSearches = []
|
||||
},
|
||||
|
||||
// Reset to initial state
|
||||
resetFilters: (state) => {
|
||||
state.savedFilters = []
|
||||
state.activeFilterId = null
|
||||
state.recentSearches = []
|
||||
},
|
||||
|
||||
// Error handling
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload
|
||||
},
|
||||
|
||||
clearError: (state) => {
|
||||
state.error = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
setActiveFilter,
|
||||
addSearchQuery,
|
||||
removeSearchQuery,
|
||||
clearSearchHistory,
|
||||
resetFilters,
|
||||
setError,
|
||||
clearError
|
||||
} = emailFiltersSlice.actions
|
||||
|
||||
// Selectors
|
||||
export const selectSavedFilters = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.savedFilters
|
||||
|
||||
export const selectFilterById = (state: { emailFilters: EmailFilterState }, id: string) =>
|
||||
state.emailFilters.savedFilters.find((f) => f.id === id)
|
||||
|
||||
export const selectActiveFilterId = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.activeFilterId
|
||||
|
||||
export const selectActiveFilter = (state: { emailFilters: EmailFilterState }) => {
|
||||
if (!state.emailFilters.activeFilterId) return null
|
||||
return state.emailFilters.savedFilters.find(
|
||||
(f) => f.id === state.emailFilters.activeFilterId
|
||||
) || null
|
||||
}
|
||||
|
||||
export const selectRecentSearches = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.recentSearches
|
||||
|
||||
export const selectTopSearches = (state: { emailFilters: EmailFilterState }, limit: number = 5) =>
|
||||
state.emailFilters.recentSearches
|
||||
.sort((a, b) => b.frequency - a.frequency)
|
||||
.slice(0, limit)
|
||||
|
||||
export const selectFilterCount = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.savedFilters.length
|
||||
|
||||
export const selectSearchHistoryCount = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.recentSearches.length
|
||||
|
||||
export const selectError = (state: { emailFilters: EmailFilterState }) =>
|
||||
state.emailFilters.error
|
||||
|
||||
export default emailFiltersSlice.reducer
|
||||
224
redux/email/src/slices/emailListSlice.ts
Normal file
224
redux/email/src/slices/emailListSlice.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Redux Slice for Email List State Management
|
||||
* Handles message list display, selection, filtering, and pagination
|
||||
*/
|
||||
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface EmailMessage {
|
||||
id: string
|
||||
from: string
|
||||
subject: string
|
||||
preview: string
|
||||
receivedAt: number
|
||||
isRead: boolean
|
||||
isStarred: boolean
|
||||
attachmentCount: number
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
totalMessages: number
|
||||
}
|
||||
|
||||
export interface EmailListState {
|
||||
messages: EmailMessage[]
|
||||
selectedMessageId: string | null
|
||||
filter: 'all' | 'unread' | 'starred' | 'spam' | 'custom'
|
||||
searchQuery: string
|
||||
pagination: PaginationState
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: EmailListState = {
|
||||
messages: [],
|
||||
selectedMessageId: null,
|
||||
filter: 'all',
|
||||
searchQuery: '',
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
totalMessages: 0
|
||||
},
|
||||
isLoading: false,
|
||||
error: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Async thunk to fetch messages from API
|
||||
* Handles pagination and filtering
|
||||
*/
|
||||
export const fetchMessages = createAsyncThunk<
|
||||
{ messages: EmailMessage[]; total: number },
|
||||
{
|
||||
folderId: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
filter?: string
|
||||
search?: string
|
||||
},
|
||||
{ rejectValue: string }
|
||||
>(
|
||||
'emailList/fetchMessages',
|
||||
async (params, { rejectWithValue }) => {
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
folderId: params.folderId,
|
||||
page: (params.page || 1).toString(),
|
||||
pageSize: (params.pageSize || 20).toString(),
|
||||
...(params.filter && { filter: params.filter }),
|
||||
...(params.search && { search: params.search })
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/v1/email/messages?${query}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch messages')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const emailListSlice = createSlice({
|
||||
name: 'emailList',
|
||||
initialState,
|
||||
reducers: {
|
||||
// Message selection
|
||||
setSelectedMessage: (state, action: PayloadAction<string | null>) => {
|
||||
state.selectedMessageId = action.payload
|
||||
},
|
||||
|
||||
// Filter management
|
||||
setFilter: (state, action: PayloadAction<'all' | 'unread' | 'starred' | 'spam' | 'custom'>) => {
|
||||
state.filter = action.payload
|
||||
state.pagination.currentPage = 1
|
||||
},
|
||||
|
||||
// Search functionality
|
||||
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.searchQuery = action.payload
|
||||
state.pagination.currentPage = 1
|
||||
},
|
||||
|
||||
// Clear search
|
||||
clearSearchQuery: (state) => {
|
||||
state.searchQuery = ''
|
||||
state.pagination.currentPage = 1
|
||||
},
|
||||
|
||||
// Pagination
|
||||
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||
state.pagination.currentPage = action.payload
|
||||
},
|
||||
|
||||
setPageSize: (state, action: PayloadAction<number>) => {
|
||||
state.pagination.pageSize = action.payload
|
||||
state.pagination.currentPage = 1
|
||||
},
|
||||
|
||||
// Message flag updates
|
||||
toggleMessageRead: (state, action: PayloadAction<string>) => {
|
||||
const message = state.messages.find((m) => m.id === action.payload)
|
||||
if (message) {
|
||||
message.isRead = !message.isRead
|
||||
}
|
||||
},
|
||||
|
||||
toggleMessageStarred: (state, action: PayloadAction<string>) => {
|
||||
const message = state.messages.find((m) => m.id === action.payload)
|
||||
if (message) {
|
||||
message.isStarred = !message.isStarred
|
||||
}
|
||||
},
|
||||
|
||||
// Remove message from list
|
||||
removeMessage: (state, action: PayloadAction<string>) => {
|
||||
state.messages = state.messages.filter((m) => m.id !== action.payload)
|
||||
if (state.selectedMessageId === action.payload) {
|
||||
state.selectedMessageId = null
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all messages
|
||||
clearMessages: (state) => {
|
||||
state.messages = []
|
||||
state.selectedMessageId = null
|
||||
},
|
||||
|
||||
// Error handling
|
||||
clearError: (state) => {
|
||||
state.error = null
|
||||
}
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
// Fetch messages - pending
|
||||
.addCase(fetchMessages.pending, (state) => {
|
||||
state.isLoading = true
|
||||
state.error = null
|
||||
})
|
||||
// Fetch messages - fulfilled
|
||||
.addCase(fetchMessages.fulfilled, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.messages = action.payload.messages
|
||||
state.pagination.totalMessages = action.payload.total
|
||||
})
|
||||
// Fetch messages - rejected
|
||||
.addCase(fetchMessages.rejected, (state, action) => {
|
||||
state.isLoading = false
|
||||
state.error = action.payload || 'Failed to fetch messages'
|
||||
state.messages = []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const {
|
||||
setSelectedMessage,
|
||||
setFilter,
|
||||
setSearchQuery,
|
||||
clearSearchQuery,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
toggleMessageRead,
|
||||
toggleMessageStarred,
|
||||
removeMessage,
|
||||
clearMessages,
|
||||
clearError
|
||||
} = emailListSlice.actions
|
||||
|
||||
// Selectors
|
||||
export const selectMessages = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.messages
|
||||
|
||||
export const selectSelectedMessageId = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.selectedMessageId
|
||||
|
||||
export const selectFilter = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.filter
|
||||
|
||||
export const selectSearchQuery = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.searchQuery
|
||||
|
||||
export const selectPagination = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.pagination
|
||||
|
||||
export const selectIsLoading = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.isLoading
|
||||
|
||||
export const selectError = (state: { emailList: EmailListState }) =>
|
||||
state.emailList.error
|
||||
|
||||
export const selectSelectedMessage = (state: { emailList: EmailListState }) => {
|
||||
if (!state.emailList.selectedMessageId) return null
|
||||
return state.emailList.messages.find((m) => m.id === state.emailList.selectedMessageId) || null
|
||||
}
|
||||
|
||||
export default emailListSlice.reducer
|
||||
92
redux/email/src/slices/index.ts
Normal file
92
redux/email/src/slices/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Email Redux Slices - Barrel Export
|
||||
* Exports all email slices, actions, and selectors
|
||||
*/
|
||||
|
||||
// Email List Slice
|
||||
export {
|
||||
emailListSlice,
|
||||
fetchMessages,
|
||||
setSelectedMessage,
|
||||
setFilter,
|
||||
setSearchQuery,
|
||||
clearSearchQuery,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
toggleMessageRead,
|
||||
toggleMessageStarred,
|
||||
removeMessage,
|
||||
clearMessages,
|
||||
selectMessages,
|
||||
selectSelectedMessageId,
|
||||
selectFilter,
|
||||
selectSearchQuery,
|
||||
selectPagination,
|
||||
selectIsLoading,
|
||||
selectError,
|
||||
selectSelectedMessage
|
||||
} from './emailListSlice'
|
||||
export type { EmailListState, EmailMessage, PaginationState } from './emailListSlice'
|
||||
|
||||
// Email Detail Slice
|
||||
export {
|
||||
emailDetailSlice,
|
||||
fetchEmailDetail,
|
||||
fetchConversationThread,
|
||||
setSelectedEmail,
|
||||
addToThread,
|
||||
clearThread,
|
||||
setEmailRead,
|
||||
setEmailStarred,
|
||||
clearEmailDetail,
|
||||
selectSelectedEmail,
|
||||
selectThreadMessages,
|
||||
selectConversationThread
|
||||
} from './emailDetailSlice'
|
||||
export type { EmailDetailState, EmailDetail, ThreadMessage } from './emailDetailSlice'
|
||||
|
||||
// Email Compose Slice
|
||||
export {
|
||||
emailComposeSlice,
|
||||
saveDraftAsync,
|
||||
sendEmailAsync,
|
||||
fetchDrafts,
|
||||
createDraft,
|
||||
updateDraft,
|
||||
updateDraftMultiple,
|
||||
addRecipient,
|
||||
removeRecipient,
|
||||
addAttachment,
|
||||
removeAttachment,
|
||||
setCurrentDraft,
|
||||
clearDraft,
|
||||
deleteDraft,
|
||||
selectCurrentDraft,
|
||||
selectDrafts,
|
||||
selectDraftCount,
|
||||
selectSuccessMessage
|
||||
} from './emailComposeSlice'
|
||||
export type { ComposeDraftState, EmailDraft } from './emailComposeSlice'
|
||||
|
||||
// Email Filters Slice
|
||||
export {
|
||||
emailFiltersSlice,
|
||||
addFilter,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
setActiveFilter,
|
||||
addSearchQuery,
|
||||
removeSearchQuery,
|
||||
clearSearchHistory,
|
||||
resetFilters,
|
||||
selectSavedFilters,
|
||||
selectFilterById,
|
||||
selectActiveFilterId,
|
||||
selectActiveFilter,
|
||||
selectRecentSearches,
|
||||
selectTopSearches,
|
||||
selectFilterCount,
|
||||
selectSearchHistoryCount
|
||||
} from './emailFiltersSlice'
|
||||
export type { EmailFilterState, EmailFilter, SearchQuery } from './emailFiltersSlice'
|
||||
Reference in New Issue
Block a user