replace old client with new client from commafeed-ui repository

This commit is contained in:
Athou
2022-08-13 10:56:07 +02:00
parent ac7b6eeb21
commit 04894f118b
183 changed files with 20326 additions and 12678 deletions

View File

@@ -0,0 +1,146 @@
/* eslint-disable import/first */
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DeepMockProxy, mockDeep, mockReset } from "vitest-mock-extended"
vi.doMock("app/client", () => ({ client: mockDeep() }))
import { configureStore } from "@reduxjs/toolkit"
import { client } from "app/client"
import { reducers } from "app/store"
import { Entries, Entry } from "app/types"
import { AxiosResponse } from "axios"
import { loadEntries, loadMoreEntries, markAllEntries, markEntry } from "./entries"
describe("entries", () => {
const mockClient = client as DeepMockProxy<typeof client>
beforeEach(() => {
mockReset(mockClient)
})
it("loads entries", async () => {
mockClient.feed.getEntries.mockResolvedValue({
data: {
entries: [{ id: "3" } as Entry],
hasMore: false,
name: "my-feed",
errorCount: 3,
feedLink: "https://mysite.com/feed",
timestamp: 123,
ignoredReadStatus: false,
},
} as AxiosResponse<Entries>)
const store = configureStore({ reducer: reducers })
const promise = store.dispatch(
loadEntries({
sourceType: "feed",
req: {
id: "feed-id",
},
})
)
expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([])
expect(store.getState().entries.hasMore).toBe(true)
expect(store.getState().entries.sourceLabel).toBe("")
expect(store.getState().entries.sourceWebsiteUrl).toBe("")
expect(store.getState().entries.timestamp).toBeUndefined()
await promise
expect(store.getState().entries.source.type).toBe("feed")
expect(store.getState().entries.source.id).toBe("feed-id")
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }])
expect(store.getState().entries.hasMore).toBe(false)
expect(store.getState().entries.sourceLabel).toBe("my-feed")
expect(store.getState().entries.sourceWebsiteUrl).toBe("https://mysite.com/feed")
expect(store.getState().entries.timestamp).toBe(123)
})
it("loads more entries", async () => {
mockClient.category.getEntries.mockResolvedValue({
data: {
entries: [{ id: "4" } as Entry],
hasMore: false,
name: "my-feed",
errorCount: 3,
feedLink: "https://mysite.com/feed",
timestamp: 123,
ignoredReadStatus: false,
},
} as AxiosResponse<Entries>)
const store = configureStore({
reducer: reducers,
preloadedState: {
entries: {
source: {
type: "category",
id: "category-id",
},
sourceLabel: "",
sourceWebsiteUrl: "",
entries: [{ id: "3" } as Entry],
hasMore: true,
},
},
})
const promise = store.dispatch(loadMoreEntries())
await promise
expect(store.getState().entries.entries).toStrictEqual([{ id: "3" }, { id: "4" }])
expect(store.getState().entries.hasMore).toBe(false)
})
it("marks an entry as read", async () => {
const store = configureStore({
reducer: reducers,
preloadedState: {
entries: {
source: {
type: "category",
id: "category-id",
},
sourceLabel: "",
sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true,
},
},
})
store.dispatch(markEntry({ entry: { id: "3" } as Entry, read: true }))
expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true },
{ id: "4", read: false },
])
expect(mockClient.entry.mark).toHaveBeenCalledWith({ id: "3", read: true })
})
it("marks all entries as read", async () => {
const store = configureStore({
reducer: reducers,
preloadedState: {
entries: {
source: {
type: "category",
id: "category-id",
},
sourceLabel: "",
sourceWebsiteUrl: "",
entries: [{ id: "3", read: false } as Entry, { id: "4", read: false } as Entry],
hasMore: true,
},
},
})
store.dispatch(markAllEntries({ sourceType: "category", req: { id: "all", read: true } }))
expect(store.getState().entries.entries).toStrictEqual([
{ id: "3", read: true },
{ id: "4", read: true },
])
expect(mockClient.category.markEntries).toHaveBeenCalledWith({ id: "all", read: true })
})
})

View File

@@ -0,0 +1,205 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
import { client } from "app/client"
import { Constants } from "app/constants"
import { RootState } from "app/store"
import { Entries, Entry, GetEntriesRequest, MarkRequest } from "app/types"
export type EntrySourceType = "category" | "feed"
export type EntrySource = { type: EntrySourceType; id: string }
export type ExpendableEntry = Entry & { expanded?: boolean }
interface EntriesState {
/** selected source */
source: EntrySource
sourceLabel: string
sourceWebsiteUrl: string
entries: ExpendableEntry[]
/** stores when the first batch of entries were retrieved
*
* this is used when marking all entries of a feed/category to only mark entries up to that timestamp as newer entries were potentially never shown
*/
timestamp?: number
selectedEntryId?: string
hasMore: boolean
}
const initialState: EntriesState = {
source: {
type: "category",
id: Constants.categoryIds.all,
},
sourceLabel: "",
sourceWebsiteUrl: "",
entries: [],
hasMore: true,
}
const getEndpoint = (sourceType: EntrySourceType) => (sourceType === "category" ? client.category.getEntries : client.feed.getEntries)
export const loadEntries = createAsyncThunk<Entries, { req: GetEntriesRequest; sourceType: EntrySourceType }, { state: RootState }>(
"entries/load",
async arg => {
const endpoint = getEndpoint(arg.sourceType)
const result = await endpoint({
...arg.req,
offset: 0,
limit: 50,
})
return result.data
}
)
export const loadMoreEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/loadMore", async (_, thunkApi) => {
const state = thunkApi.getState()
const offset =
state.user.settings?.readingMode === "all" ? state.entries.entries.length : state.entries.entries.filter(e => !e.read).length
const endpoint = getEndpoint(state.entries.source.type)
const result = await endpoint({
id: state.entries.source.id,
readType: state.user.settings?.readingMode,
order: state.user.settings?.readingOrder,
offset,
limit: 50,
})
return result.data
})
export const reloadEntries = createAsyncThunk<Entries, void, { state: RootState }>("entries/reload", async (_, thunkApi) => {
const state = thunkApi.getState()
const endpoint = getEndpoint(state.entries.source.type)
const result = await endpoint({
id: state.entries.source.id,
readType: state.user.settings?.readingMode,
order: state.user.settings?.readingOrder,
offset: 0,
limit: 50,
})
return result.data
})
export const markEntry = createAsyncThunk(
"entries/entry/mark",
(arg: { entry: Entry; read: boolean }) => {
client.entry.mark({
id: arg.entry.id,
read: arg.read,
})
},
{
condition: arg => arg.entry.read !== arg.read,
}
)
export const markAllEntries = createAsyncThunk("entries/entry/markAll", (arg: { sourceType: EntrySourceType; req: MarkRequest }) => {
const endpoint = arg.sourceType === "category" ? client.category.markEntries : client.feed.markEntries
endpoint(arg.req)
})
export const selectEntry = createAsyncThunk<void, Entry, { state: RootState }>("entries/entry/select", (arg, thunkApi) => {
const state = thunkApi.getState()
const entry = state.entries.entries.find(e => e.id === arg.id)
if (!entry) return
// only mark entry as read if we're expanding
if (!entry.expanded) {
thunkApi.dispatch(markEntry({ entry, read: true }))
}
// set entry as selected
thunkApi.dispatch(entriesSlice.actions.setSelectedEntry(entry))
// collapse or expand entry if needed
const previouslySelectedEntry = state.entries.entries.find(e => e.id === state.entries.selectedEntryId)
if (entry === previouslySelectedEntry) {
// selecting an entry already selected toggles expanded status
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: !entry.expanded }))
} else {
if (previouslySelectedEntry) {
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry: previouslySelectedEntry, expanded: false }))
}
thunkApi.dispatch(entriesSlice.actions.setEntryExpanded({ entry, expanded: true }))
}
})
export const selectPreviousEntry = createAsyncThunk<void, void, { state: RootState }>("entries/entry/selectPrevious", (_, thunkApi) => {
const state = thunkApi.getState()
const { entries } = state.entries
const previousIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) - 1
if (previousIndex >= 0) {
thunkApi.dispatch(selectEntry(entries[previousIndex]))
}
})
export const selectNextEntry = createAsyncThunk<void, void, { state: RootState }>("entries/entry/selectNext", (_, thunkApi) => {
const state = thunkApi.getState()
const { entries } = state.entries
const nextIndex = entries.findIndex(e => e.id === state.entries.selectedEntryId) + 1
if (nextIndex < entries.length) {
thunkApi.dispatch(selectEntry(entries[nextIndex]))
}
})
export const entriesSlice = createSlice({
name: "entries",
initialState,
reducers: {
setSelectedEntry: (state, action: PayloadAction<Entry>) => {
state.selectedEntryId = action.payload.id
},
setEntryExpanded: (state, action: PayloadAction<{ entry: Entry; expanded: boolean }>) => {
state.entries
.filter(e => e.id === action.payload.entry.id)
.forEach(e => {
e.expanded = action.payload.expanded
})
},
},
extraReducers: builder => {
builder.addCase(markEntry.pending, (state, action) => {
state.entries
.filter(e => e.id === action.meta.arg.entry.id)
.forEach(e => {
e.read = action.meta.arg.read
})
})
builder.addCase(markAllEntries.pending, state => {
state.entries.forEach(e => {
e.read = true
})
})
builder.addCase(loadEntries.pending, (state, action) => {
state.source = {
type: action.meta.arg.sourceType,
id: action.meta.arg.req.id,
}
state.entries = []
state.timestamp = undefined
state.sourceLabel = ""
state.sourceWebsiteUrl = ""
state.hasMore = true
state.selectedEntryId = undefined
})
builder.addCase(loadEntries.fulfilled, (state, action) => {
state.entries = action.payload.entries
state.timestamp = action.payload.timestamp
state.sourceLabel = action.payload.name
state.sourceWebsiteUrl = action.payload.feedLink
state.hasMore = action.payload.hasMore
})
builder.addCase(loadMoreEntries.fulfilled, (state, action) => {
// remove already existing entries
const entriesToAdd = action.payload.entries.filter(e => !state.entries.some(e2 => e.id === e2.id))
state.entries = [...state.entries, ...entriesToAdd]
state.hasMore = action.payload.hasMore
})
builder.addCase(reloadEntries.pending, state => {
state.entries = []
state.timestamp = undefined
state.sourceLabel = ""
state.sourceWebsiteUrl = ""
state.hasMore = true
state.selectedEntryId = undefined
})
builder.addCase(reloadEntries.fulfilled, (state, action) => {
state.entries = action.payload.entries
state.timestamp = action.payload.timestamp
state.sourceLabel = action.payload.name
state.sourceWebsiteUrl = action.payload.feedLink
state.hasMore = action.payload.hasMore
})
},
})
export default entriesSlice.reducer

View File

@@ -0,0 +1,10 @@
import { store } from "app/store"
import { describe, expect, it } from "vitest"
import { redirectToCategory } from "./redirect"
describe("redirects", () => {
it("redirects to category", async () => {
await store.dispatch(redirectToCategory("1"))
expect(store.getState().redirect.to).toBe("/app/category/1")
})
})

View File

@@ -0,0 +1,52 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
import { Constants } from "app/constants"
import { RootState } from "app/store"
interface RedirectState {
to?: string
}
const initialState: RedirectState = {}
export const redirectToLogin = createAsyncThunk("redirect/login", (_, thunkApi) => thunkApi.dispatch(redirectTo("/login")))
export const redirectToRegistration = createAsyncThunk("redirect/register", (_, thunkApi) => thunkApi.dispatch(redirectTo("/register")))
export const redirectToPasswordRecovery = createAsyncThunk("redirect/passwordRecovery", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/passwordRecovery"))
)
export const redirectToSelectedSource = createAsyncThunk<void, void, { state: RootState }>("redirect/selectedSource", (_, thunkApi) => {
const { source } = thunkApi.getState().entries
thunkApi.dispatch(redirectTo(`/app/${source.type}/${source.id}`))
})
export const redirectToCategory = createAsyncThunk("redirect/category", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}`))
)
export const redirectToRootCategory = createAsyncThunk("redirect/category/root", (_, thunkApi) =>
thunkApi.dispatch(redirectToCategory(Constants.categoryIds.all))
)
export const redirectToCategoryDetails = createAsyncThunk("redirect/category/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/category/${id}/details`))
)
export const redirectToFeed = createAsyncThunk("redirect/feed", (id: string | number, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/feed/${id}`))
)
export const redirectToFeedDetails = createAsyncThunk("redirect/feed/details", (id: string, thunkApi) =>
thunkApi.dispatch(redirectTo(`/app/feed/${id}/details`))
)
export const redirectToAdd = createAsyncThunk("redirect/add", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/add")))
export const redirectToSettings = createAsyncThunk("redirect/settings", (_, thunkApi) => thunkApi.dispatch(redirectTo("/app/settings")))
export const redirectToAdminUsers = createAsyncThunk("redirect/admin/users", (_, thunkApi) =>
thunkApi.dispatch(redirectTo("/app/admin/users"))
)
export const redirectSlice = createSlice({
name: "redirect",
initialState,
reducers: {
redirectTo: (state, action: PayloadAction<string | undefined>) => {
state.to = action.payload
},
},
})
export const { redirectTo } = redirectSlice.actions
export default redirectSlice.reducer

View File

@@ -0,0 +1,23 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"
import { client } from "app/client"
import { ServerInfo } from "app/types"
interface ServerState {
serverInfos?: ServerInfo
}
const initialState: ServerState = {}
export const reloadServerInfos = createAsyncThunk("server/infos", () => client.server.getServerInfos().then(r => r.data))
export const serverSlice = createSlice({
name: "server",
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(reloadServerInfos.fulfilled, (state, action) => {
state.serverInfos = action.payload
})
},
})
export default serverSlice.reducer

View File

@@ -0,0 +1,77 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
import { client } from "app/client"
import { Category, CollapseRequest } from "app/types"
import { visitCategoryTree } from "app/utils"
import { markAllEntries, markEntry } from "./entries"
import { redirectTo } from "./redirect"
interface TreeState {
rootCategory?: Category
mobileMenuOpen: boolean
}
const initialState: TreeState = {
mobileMenuOpen: false,
}
export const reloadTree = createAsyncThunk("tree/reload", () => client.category.getRoot().then(r => r.data))
export const collapseTreeCategory = createAsyncThunk("tree/category/collapse", async (req: CollapseRequest) =>
client.category.collapse(req)
)
export const treeSlice = createSlice({
name: "tree",
initialState,
reducers: {
setMobileMenuOpen: (state, action: PayloadAction<boolean>) => {
state.mobileMenuOpen = action.payload
},
},
extraReducers: builder => {
builder.addCase(reloadTree.fulfilled, (state, action) => {
state.rootCategory = action.payload
})
builder.addCase(collapseTreeCategory.pending, (state, action) => {
if (!state.rootCategory) return
visitCategoryTree(state.rootCategory, c => {
if (+c.id === action.meta.arg.id) c.expanded = !action.meta.arg.collapse
})
})
builder.addCase(markEntry.pending, (state, action) => {
if (!state.rootCategory) return
visitCategoryTree(state.rootCategory, c =>
c.feeds
.filter(f => f.id === +action.meta.arg.entry.feedId)
.forEach(f => {
f.unread = action.meta.arg.read ? f.unread - 1 : f.unread + 1
})
)
})
builder.addCase(markAllEntries.pending, (state, action) => {
if (!state.rootCategory) return
const { sourceType } = action.meta.arg
const sourceId = action.meta.arg.req.id
visitCategoryTree(state.rootCategory, c => {
if (sourceType === "category" && c.id === sourceId) {
visitCategoryTree(c, c2 =>
c2.feeds.forEach(f => {
f.unread = 0
})
)
} else if (sourceType === "feed") {
c.feeds
.filter(f => f.id === +sourceId)
.forEach(f => {
f.unread = 0
})
}
})
})
builder.addCase(redirectTo, state => {
state.mobileMenuOpen = false
})
},
})
export const { setMobileMenuOpen } = treeSlice.actions
export default treeSlice.reducer

View File

@@ -0,0 +1,80 @@
import { t } from "@lingui/macro"
import { showNotification } from "@mantine/notifications"
import { createAsyncThunk, createSlice, isAnyOf } from "@reduxjs/toolkit"
import { client } from "app/client"
import { RootState } from "app/store"
import { ReadingMode, ReadingOrder, Settings, UserModel } from "app/types"
interface UserState {
settings?: Settings
profile?: UserModel
}
const initialState: UserState = {}
export const reloadSettings = createAsyncThunk("settings/reload", () => client.user.getSettings().then(r => r.data))
export const reloadProfile = createAsyncThunk("profile/reload", () => client.user.getProfile().then(r => r.data))
export const changeReadingMode = createAsyncThunk<void, ReadingMode, { state: RootState }>(
"settings/readingMode",
(readingMode, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, readingMode })
}
)
export const changeReadingOrder = createAsyncThunk<void, ReadingOrder, { state: RootState }>(
"settings/readingOrder",
(readingOrder, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, readingOrder })
}
)
export const changeLanguage = createAsyncThunk<void, string, { state: RootState }>("settings/language", (language, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, language })
})
export const changeScrollSpeed = createAsyncThunk<void, boolean, { state: RootState }>("settings/scrollSpeed", (speed, thunkApi) => {
const { settings } = thunkApi.getState().user
if (!settings) return
client.user.saveSettings({ ...settings, scrollSpeed: speed ? 400 : 0 })
})
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(reloadSettings.fulfilled, (state, action) => {
state.settings = action.payload
})
builder.addCase(reloadProfile.fulfilled, (state, action) => {
state.profile = action.payload
})
builder.addCase(changeReadingMode.pending, (state, action) => {
if (!state.settings) return
state.settings.readingMode = action.meta.arg
})
builder.addCase(changeReadingOrder.pending, (state, action) => {
if (!state.settings) return
state.settings.readingOrder = action.meta.arg
})
builder.addCase(changeLanguage.pending, (state, action) => {
if (!state.settings) return
state.settings.language = action.meta.arg
})
builder.addCase(changeScrollSpeed.pending, (state, action) => {
if (!state.settings) return
state.settings.scrollSpeed = action.meta.arg ? 400 : 0
})
builder.addMatcher(isAnyOf(changeLanguage.fulfilled, changeScrollSpeed.fulfilled), () => {
showNotification({
message: t`Settings saved.`,
color: "green",
})
})
},
})
export default userSlice.reducer