mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
Merge fcc73181e3 into 5c6440e1ad
This commit is contained in:
commit
d5802c071f
|
|
@ -12,12 +12,14 @@ import {
|
|||
seedQueryData,
|
||||
} from "~/composables/api/test-helpers"
|
||||
import NotificationBox from "./NotificationBox.vue"
|
||||
import NotificationRow from "./NotificationRow.vue"
|
||||
import {
|
||||
at,
|
||||
findButtonByText,
|
||||
mockAuthOrganization,
|
||||
renderedIconNames,
|
||||
seedAuthOrganization,
|
||||
settleMutations,
|
||||
t,
|
||||
} from "./test-helpers"
|
||||
|
||||
|
|
@ -82,6 +84,7 @@ describe("<NotificationBox>", { concurrent: false }, () => {
|
|||
|
||||
afterEach(() => {
|
||||
disposeMockEndpoints()
|
||||
useWebSocketStateStore().state = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
|
|
@ -93,6 +96,7 @@ describe("<NotificationBox>", { concurrent: false }, () => {
|
|||
const wrapper = await mountBox()
|
||||
|
||||
expect(wrapper.text()).toContain(t("notification.empty-title"))
|
||||
expect(wrapper.text()).toContain(t("notification.empty-description"))
|
||||
})
|
||||
|
||||
it("lists one row per notification", async ({ expect }) => {
|
||||
|
|
@ -215,6 +219,297 @@ describe("<NotificationBox>", { concurrent: false }, () => {
|
|||
expect(wrapper.emitted("close-notification-box")).toHaveLength(1)
|
||||
})
|
||||
|
||||
// filter changes share the mounted query and the endpoint registry.
|
||||
describe("filtering", { concurrent: false }, () => {
|
||||
it("selects all notifications by default", async ({ expect }) => {
|
||||
seedNotifications([
|
||||
makeNotification(),
|
||||
makeNotification({ id: "read", read: true }),
|
||||
])
|
||||
|
||||
const wrapper = await mountBox()
|
||||
|
||||
expect(wrapper.get("[role='group']").attributes("aria-label")).toBe(
|
||||
t("notification.filters.label"),
|
||||
)
|
||||
expect(wrapper.get("button[aria-pressed='true']").text()).toBe(
|
||||
t("notification.filters.all"),
|
||||
)
|
||||
expect(rows(wrapper)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ filter: "unread", read: false },
|
||||
{ filter: "read", read: true },
|
||||
])(
|
||||
"fetches $filter notifications beyond the first unfiltered page",
|
||||
async ({ filter, read }, { expect }) => {
|
||||
disposeMockEndpoints()
|
||||
const notifications = Array.from({ length: 52 }, (_unused, index) =>
|
||||
makeNotification({
|
||||
id: `n${index}`,
|
||||
read: index < 50 ? !read : read,
|
||||
}),
|
||||
)
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", (call) => {
|
||||
const filtered =
|
||||
call.query["filter-read_eq"] === undefined
|
||||
? notifications
|
||||
: notifications.filter(
|
||||
(notification) =>
|
||||
String(notification.read) === call.query["filter-read_eq"],
|
||||
)
|
||||
|
||||
return {
|
||||
notifications: filtered.slice(0, 50),
|
||||
pageCount: Math.ceil(filtered.length / 50),
|
||||
}
|
||||
})
|
||||
const countCalls = mockEndpoint(
|
||||
"GET",
|
||||
"/api/notifications/count",
|
||||
() => ({
|
||||
count: notifications.filter((notification) => !notification.read)
|
||||
.length,
|
||||
}),
|
||||
)
|
||||
const wrapper = await mountBox()
|
||||
await settleMutations()
|
||||
expect(rows(wrapper)).toHaveLength(50)
|
||||
|
||||
await findButtonByText(
|
||||
wrapper,
|
||||
t(`notification.filters.${filter}`),
|
||||
).trigger("click")
|
||||
await settleMutations()
|
||||
|
||||
expect(notificationIds(wrapper)).toEqual(["n50", "n51"])
|
||||
expect(wrapper.get("button[aria-pressed='true']").text()).toBe(
|
||||
t(`notification.filters.${filter}`),
|
||||
)
|
||||
expect(infiniteScroll.canLoadMore()).toBe(false)
|
||||
expect(listCalls.map((call) => call.query)).toEqual([
|
||||
{ limit: "50", page: "1" },
|
||||
{ limit: "50", page: "1", "filter-read_eq": String(read) },
|
||||
])
|
||||
expect(countCalls.map((call) => call.query)).toEqual([
|
||||
{ read: "false" },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it("starts a new filter at page one and resumes each filter's cached pages", async ({
|
||||
expect,
|
||||
}) => {
|
||||
disposeMockEndpoints()
|
||||
const calls = mockEndpoint("GET", "/api/notifications", (call) => {
|
||||
const read = call.query["filter-read_eq"]
|
||||
const filter = typeof read === "string" ? read : "all"
|
||||
|
||||
return {
|
||||
notifications: [
|
||||
makeNotification({
|
||||
id: `${filter}-${String(call.query.page)}`,
|
||||
}),
|
||||
],
|
||||
pageCount: read === "false" ? 2 : 3,
|
||||
}
|
||||
})
|
||||
seedPages(
|
||||
[
|
||||
[makeNotification({ id: "all-1" })],
|
||||
[makeNotification({ id: "all-2" })],
|
||||
],
|
||||
3,
|
||||
)
|
||||
const wrapper = await mountBox()
|
||||
const scroll = wrapper.get(".overflow-y-auto").element
|
||||
scroll.scrollTop = 120
|
||||
|
||||
await findButtonByText(wrapper, t("notification.filters.unread")).trigger(
|
||||
"click",
|
||||
)
|
||||
await settleMutations()
|
||||
expect(scroll.scrollTop).toBe(0)
|
||||
await infiniteScroll.load()
|
||||
await settleMutations()
|
||||
expect(notificationIds(wrapper)).toEqual(["false-1", "false-2"])
|
||||
expect(infiniteScroll.canLoadMore()).toBe(false)
|
||||
await findButtonByText(wrapper, t("notification.filters.all")).trigger(
|
||||
"click",
|
||||
)
|
||||
await settleMutations()
|
||||
expect(notificationIds(wrapper)).toEqual(["all-1", "all-2"])
|
||||
await infiniteScroll.load()
|
||||
await settleMutations()
|
||||
|
||||
expect(notificationIds(wrapper)).toEqual(["all-1", "all-2", "all-3"])
|
||||
expect(calls.map((call) => call.query)).toEqual([
|
||||
{ limit: "50", page: "1", "filter-read_eq": "false" },
|
||||
{ limit: "50", page: "2", "filter-read_eq": "false" },
|
||||
{ limit: "50", page: "3" },
|
||||
])
|
||||
})
|
||||
|
||||
it("describes an empty filtered list without saying the whole inbox is empty", async ({
|
||||
expect,
|
||||
}) => {
|
||||
seedNotifications([makeNotification()], 1)
|
||||
const wrapper = await mountBox()
|
||||
|
||||
await findButtonByText(wrapper, t("notification.filters.read")).trigger(
|
||||
"click",
|
||||
)
|
||||
await settleMutations()
|
||||
|
||||
expect(wrapper.text()).toContain(t("notification.filtered-empty-title"))
|
||||
expect(wrapper.text()).toContain(
|
||||
t("notification.filtered-empty-description"),
|
||||
)
|
||||
expect(wrapper.text()).not.toContain(t("notification.empty-title"))
|
||||
expect(rows(wrapper)).toHaveLength(0)
|
||||
expect(
|
||||
findButtonByText(wrapper, t("notification.read-all-button")).exists(),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "one notification", all: false },
|
||||
{ name: "all notifications", all: true },
|
||||
])(
|
||||
"updates filtered results and the unread count after marking $name read",
|
||||
async ({ all }, { expect }) => {
|
||||
disposeMockEndpoints()
|
||||
let notifications = [
|
||||
makeNotification(),
|
||||
makeNotification({ id: "notif-2" }),
|
||||
]
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", (call) => ({
|
||||
notifications: notifications.filter(
|
||||
(notification) =>
|
||||
call.query["filter-read_eq"] === undefined ||
|
||||
String(notification.read) === call.query["filter-read_eq"],
|
||||
),
|
||||
pageCount: 1,
|
||||
}))
|
||||
const countCalls = mockEndpoint(
|
||||
"GET",
|
||||
"/api/notifications/count",
|
||||
() => ({
|
||||
count: notifications.filter((notification) => !notification.read)
|
||||
.length,
|
||||
}),
|
||||
)
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
"/api/notifications/read-status",
|
||||
(call) => {
|
||||
const { ids } = call.body as { ids: string[] }
|
||||
notifications = notifications.map((notification) => ({
|
||||
...notification,
|
||||
read:
|
||||
notification.read ||
|
||||
ids.length === 0 ||
|
||||
ids.includes(notification.id),
|
||||
}))
|
||||
|
||||
return {}
|
||||
},
|
||||
)
|
||||
const wrapper = await mountBox()
|
||||
await settleMutations()
|
||||
await findButtonByText(
|
||||
wrapper,
|
||||
t("notification.filters.unread"),
|
||||
).trigger("click")
|
||||
await settleMutations()
|
||||
|
||||
await findButtonByText(
|
||||
wrapper,
|
||||
t(
|
||||
all
|
||||
? "notification.read-all-button"
|
||||
: "notification.actions.mark-read",
|
||||
),
|
||||
).trigger("click")
|
||||
await settleMutations()
|
||||
|
||||
expect(notificationIds(wrapper)).toEqual(all ? [] : ["notif-2"])
|
||||
expect(wrapper.get("button[aria-pressed='true']").text()).toBe(
|
||||
t("notification.filters.unread"),
|
||||
)
|
||||
expect(
|
||||
wrapper
|
||||
.findAll("button")
|
||||
.some(
|
||||
(button) => button.text() === t("notification.read-all-button"),
|
||||
),
|
||||
).toBe(!all)
|
||||
expect(putCalls.map((call) => call.body)).toEqual([
|
||||
{ ids: all ? [] : ["notif-1"] },
|
||||
])
|
||||
expect(countCalls).toHaveLength(2)
|
||||
expect(listCalls.map((call) => call.query["filter-read_eq"])).toEqual([
|
||||
undefined,
|
||||
"false",
|
||||
"false",
|
||||
])
|
||||
expect(toast.custom).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it("preserves the selected filter on live updates and invalidates inactive filters", async ({
|
||||
expect,
|
||||
}) => {
|
||||
disposeMockEndpoints()
|
||||
let notifications = [makeNotification()]
|
||||
let onNotification: () => void = () => undefined
|
||||
const subscribe = vi.fn((_topic: string, callback: () => void) => {
|
||||
onNotification = callback
|
||||
|
||||
return vi.fn()
|
||||
})
|
||||
useWebSocketStateStore().state = { subscribe } as never
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", () => ({
|
||||
notifications,
|
||||
pageCount: 1,
|
||||
}))
|
||||
const countCalls = mockEndpoint(
|
||||
"GET",
|
||||
"/api/notifications/count",
|
||||
() => ({ count: notifications.length }),
|
||||
)
|
||||
const wrapper = await mountBox()
|
||||
await settleMutations()
|
||||
await findButtonByText(wrapper, t("notification.filters.unread")).trigger(
|
||||
"click",
|
||||
)
|
||||
await settleMutations()
|
||||
notifications = [...notifications, makeNotification({ id: "notif-2" })]
|
||||
|
||||
onNotification()
|
||||
await settleMutations()
|
||||
|
||||
expect(wrapper.get("button[aria-pressed='true']").text()).toBe(
|
||||
t("notification.filters.unread"),
|
||||
)
|
||||
expect(notificationIds(wrapper)).toEqual(["notif-1", "notif-2"])
|
||||
await findButtonByText(wrapper, t("notification.filters.all")).trigger(
|
||||
"click",
|
||||
)
|
||||
await settleMutations()
|
||||
expect(notificationIds(wrapper)).toEqual(["notif-1", "notif-2"])
|
||||
expect(listCalls.map((call) => call.query["filter-read_eq"])).toEqual([
|
||||
undefined,
|
||||
"false",
|
||||
"false",
|
||||
undefined,
|
||||
])
|
||||
expect(countCalls).toHaveLength(2)
|
||||
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loading more", { concurrent: false }, () => {
|
||||
it.for([
|
||||
{ name: "offers more while the server has pages", input: 2 },
|
||||
|
|
@ -542,6 +837,43 @@ describe("<NotificationBox>", { concurrent: false }, () => {
|
|||
useWebSocketStateStore().state = null
|
||||
})
|
||||
|
||||
it("still refreshes the unread count when a live list refresh fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
disposeMockEndpoints()
|
||||
let onNotification: () => void = () => undefined
|
||||
const subscribe = vi.fn((_topic: string, callback: () => void) => {
|
||||
onNotification = callback
|
||||
|
||||
return vi.fn()
|
||||
})
|
||||
useWebSocketStateStore().state = { subscribe } as never
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", () => {
|
||||
throw createError({ statusCode: 400 })
|
||||
})
|
||||
const countCalls = mockEndpoint(
|
||||
"GET",
|
||||
"/api/notifications/count",
|
||||
() => ({ count: 2 }),
|
||||
)
|
||||
seedNotifications([])
|
||||
const wrapper = await mountBox()
|
||||
|
||||
onNotification()
|
||||
await settleMutations()
|
||||
|
||||
expect(
|
||||
findButtonByText(wrapper, t("notification.read-all-button")).exists(),
|
||||
).toBe(true)
|
||||
expect(wrapper.get("button[aria-pressed='true']").text()).toBe(
|
||||
t("notification.filters.all"),
|
||||
)
|
||||
expect(infiniteScroll.canLoadMore()).toBe(false)
|
||||
expect(listCalls).toHaveLength(1)
|
||||
expect(countCalls).toHaveLength(1)
|
||||
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("unsubscribes when it goes away", async ({ expect }) => {
|
||||
const unsubscribe = vi.fn()
|
||||
useWebSocketStateStore().state = {
|
||||
|
|
@ -576,13 +908,17 @@ function seedPages(pages: unknown[][], pageCount = pages.length, unread = 0) {
|
|||
// a plain seed leaves the entry without its paging state, and the
|
||||
// box's query cannot mount on it
|
||||
runInApp(() => {
|
||||
setInfiniteQueryData(useQueryCache(), ["notifications", "list", 50], {
|
||||
pages: pages.map((notifications) => ({
|
||||
notifications: notifications,
|
||||
pageCount: pageCount,
|
||||
})),
|
||||
pageParams: pages.map((_page, index) => index + 1),
|
||||
})
|
||||
setInfiniteQueryData(
|
||||
useQueryCache(),
|
||||
["notifications", "list", 50, "all"],
|
||||
{
|
||||
pages: pages.map((notifications) => ({
|
||||
notifications: notifications,
|
||||
pageCount: pageCount,
|
||||
})),
|
||||
pageParams: pages.map((_page, index) => index + 1),
|
||||
},
|
||||
)
|
||||
})
|
||||
seedQueryData(["notifications", "count", false], { count: unread })
|
||||
}
|
||||
|
|
@ -613,3 +949,19 @@ function mountBox() {
|
|||
function rows(wrapper: Awaited<ReturnType<typeof mountBox>>) {
|
||||
return wrapper.findAll("[role='link']")
|
||||
}
|
||||
|
||||
function notificationIds(wrapper: Awaited<ReturnType<typeof mountBox>>) {
|
||||
return wrapper.findAllComponents(NotificationRow).map((row) => {
|
||||
const notification: unknown = row.props("notification")
|
||||
if (
|
||||
!notification ||
|
||||
typeof notification !== "object" ||
|
||||
!("id" in notification) ||
|
||||
typeof notification.id !== "string"
|
||||
) {
|
||||
throw new Error("Expected a notification with a string id")
|
||||
}
|
||||
|
||||
return notification.id
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,17 @@ const {
|
|||
useFetchManyNotifications,
|
||||
useFetchNotificationCount,
|
||||
markNotificationsRead,
|
||||
invalidateNotifications,
|
||||
} = useNotificationAPI()
|
||||
const { fetchDocumentTree } = useDocumentAPI()
|
||||
const wsState = useWebSocketStateStore()
|
||||
const { fetchOrganization } = useAuthSession()
|
||||
const fetchNotificationCount = useFetchNotificationCount({ read: false })
|
||||
const fetchNotifications = useFetchManyNotifications({ limit: 50 })
|
||||
const readFilter = ref<"all" | "unread" | "read">("all")
|
||||
const fetchNotifications = useFetchManyNotifications(() => ({
|
||||
limit: 50,
|
||||
read: readFilter.value === "all" ? undefined : readFilter.value === "read",
|
||||
}))
|
||||
const now = useNow({ interval: 60 * 1000 })
|
||||
const list = useTemplateRef("list")
|
||||
// a failed load leaves the list at its end, so without the status check
|
||||
|
|
@ -31,11 +36,16 @@ useInfiniteScroll(list, handleLoadMore, {
|
|||
distance: 100,
|
||||
canLoadMore: () =>
|
||||
fetchNotifications.hasNextPage.value &&
|
||||
fetchNotifications.status.value !== "error",
|
||||
fetchNotifications.status.value !== "error" &&
|
||||
!markNotificationsRead.isLoading.value,
|
||||
})
|
||||
|
||||
let unsubWsNotifications: (() => void) | null | undefined = null
|
||||
const isLoadingMore = ref(false)
|
||||
const filters = computed(() => [
|
||||
{ value: "all" as const, label: t("notification.filters.all") },
|
||||
{ value: "unread" as const, label: t("notification.filters.unread") },
|
||||
{ value: "read" as const, label: t("notification.filters.read") },
|
||||
])
|
||||
|
||||
const notifications = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -73,8 +83,8 @@ onMounted(() => {
|
|||
unsubWsNotifications = wsState.state?.subscribe(
|
||||
WS_NOTIFICATION_CREATION_TOPIC,
|
||||
() => {
|
||||
void fetchNotificationCount.refetch()
|
||||
void fetchNotifications.refetch()
|
||||
// refresh failures are already exposed by the queries
|
||||
void invalidateNotifications().catch(() => undefined)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -82,6 +92,12 @@ onUnmounted(() => {
|
|||
unsubWsNotifications?.()
|
||||
})
|
||||
|
||||
watch(readFilter, () => {
|
||||
if (list.value) {
|
||||
list.value.scrollTop = 0
|
||||
}
|
||||
})
|
||||
|
||||
function findDocument(notification: Notification) {
|
||||
return extractDocumentTreeElement(
|
||||
fetchDocumentTree.data.value ?? [],
|
||||
|
|
@ -153,9 +169,7 @@ async function buildNotificationHref(notification: Notification) {
|
|||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
isLoadingMore.value = true
|
||||
await fetchNotifications.loadNextPage()
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
|
||||
async function handleNotificationClick(notification: Notification) {
|
||||
|
|
@ -235,16 +249,42 @@ async function handleMarkAllRead() {
|
|||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="t('notification.filters.label')"
|
||||
class="flex shrink-0 gap-1 border-b border-border p-1.25"
|
||||
>
|
||||
<ShadcnUiButton
|
||||
v-for="filter in filters"
|
||||
:key="filter.value"
|
||||
variant="ghost"
|
||||
size="2sm"
|
||||
class="flex-1"
|
||||
:aria-pressed="readFilter === filter.value"
|
||||
:data-status="readFilter === filter.value ? 'active' : undefined"
|
||||
@click="readFilter = filter.value"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</ShadcnUiButton>
|
||||
</div>
|
||||
<div ref="list" class="flex flex-1 overflow-y-auto p-1.25">
|
||||
<div
|
||||
v-if="!notifications.length"
|
||||
class="mx-auto flex flex-col items-center gap-2 px-5 py-10 text-center"
|
||||
>
|
||||
<div class="text-base text-muted-foreground">
|
||||
{{ t("notification.empty-title") }}
|
||||
{{
|
||||
readFilter === "all"
|
||||
? t("notification.empty-title")
|
||||
: t("notification.filtered-empty-title")
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ t("notification.empty-description") }}
|
||||
{{
|
||||
readFilter === "all"
|
||||
? t("notification.empty-description")
|
||||
: t("notification.filtered-empty-description")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-1 flex-col">
|
||||
|
|
@ -270,7 +310,7 @@ async function handleMarkAllRead() {
|
|||
/>
|
||||
</section>
|
||||
<div
|
||||
v-if="isLoadingMore"
|
||||
v-if="fetchNotifications.isLoading.value"
|
||||
class="flex shrink-0 items-center justify-center py-3 text-muted-foreground"
|
||||
>
|
||||
<Icon
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { EntryKey, UseInfiniteQueryData } from "@pinia/colada"
|
||||
import { afterEach, beforeEach, describe, it } from "vitest"
|
||||
import { afterEach, beforeEach, describe, it, vi } from "vitest"
|
||||
import {
|
||||
clearQueryCache,
|
||||
disposeMockEndpoints,
|
||||
mockEndpoint,
|
||||
mockDeferredEndpoint,
|
||||
readQueryData,
|
||||
runInApp,
|
||||
seedQueryData,
|
||||
|
|
@ -12,8 +13,10 @@ import useNotificationAPI from "./useNotificationAPI"
|
|||
|
||||
type NotificationPages = UseInfiniteQueryData<NotificationsResponse, number>
|
||||
|
||||
const LIST_KEY_A = ["notifications", "list", 10] as const
|
||||
const LIST_KEY_B = ["notifications", "list", 5] as const
|
||||
const LIST_KEY_A = ["notifications", "list", 10, "all"] as const
|
||||
const LIST_KEY_B = ["notifications", "list", 5, "all"] as const
|
||||
const UNREAD_KEY = ["notifications", "list", 10, false] as const
|
||||
const READ_KEY = ["notifications", "list", 10, true] as const
|
||||
|
||||
function makeNotificationAPI() {
|
||||
return runInApp(() => useNotificationAPI())
|
||||
|
|
@ -76,6 +79,27 @@ describe("useNotificationAPI", { concurrent: false }, () => {
|
|||
afterEach(disposeMockEndpoints)
|
||||
|
||||
describe("useFetchManyNotifications", () => {
|
||||
it.for([
|
||||
{ read: false, expected: "false" },
|
||||
{ read: true, expected: "true" },
|
||||
])(
|
||||
"filters notifications on the server with read=$read",
|
||||
async ({ read, expected }, { expect }) => {
|
||||
const page = makePage([makeNotification("n1", read)])
|
||||
const calls = mockEndpoint("GET", "/api/notifications", () => page)
|
||||
const api = makeNotificationAPI()
|
||||
const params = { limit: 10, read }
|
||||
const list = runInApp(() => api.useFetchManyNotifications(params))
|
||||
|
||||
await list.refresh()
|
||||
|
||||
expect(list.data.value).toEqual(makePages(page))
|
||||
expect(calls.map((call) => call.query)).toEqual([
|
||||
{ limit: "10", page: "1", "filter-read_eq": expected },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it("fetches the first notification page", async ({ expect }) => {
|
||||
const page = makePage([makeNotification("n1", false)])
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", () => page)
|
||||
|
|
@ -113,6 +137,123 @@ describe("useNotificationAPI", { concurrent: false }, () => {
|
|||
expect(listCalls.map((call) => call.query.page)).toEqual(["1", "2"])
|
||||
})
|
||||
|
||||
it("keeps all, unread and read pages in separate caches when switching filters", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const calls = mockEndpoint("GET", "/api/notifications", (call) =>
|
||||
makePage(
|
||||
[
|
||||
makeNotification(
|
||||
notificationPageId(call.query),
|
||||
call.query["filter-read_eq"] === "true",
|
||||
),
|
||||
],
|
||||
2,
|
||||
),
|
||||
)
|
||||
const api = makeNotificationAPI()
|
||||
const params = ref<NotificationsParams>({ limit: 10 })
|
||||
const list = runInApp(() => api.useFetchManyNotifications(params))
|
||||
await list.refresh()
|
||||
await list.loadNextPage()
|
||||
params.value = { limit: 10, read: false }
|
||||
await nextTick()
|
||||
await list.refresh()
|
||||
await list.loadNextPage()
|
||||
params.value = { limit: 10, read: true }
|
||||
await nextTick()
|
||||
await list.refresh()
|
||||
|
||||
params.value = { limit: 10 }
|
||||
await nextTick()
|
||||
await list.refresh()
|
||||
|
||||
expect(list.data.value?.pageParams).toEqual([1, 2])
|
||||
expect(
|
||||
getPages(LIST_KEY_A)?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(["all-1", "all-2"])
|
||||
expect(
|
||||
getPages(UNREAD_KEY)?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(["false-1", "false-2"])
|
||||
expect(
|
||||
getPages(READ_KEY)?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(["true-1"])
|
||||
expect(calls.map((call) => call.query)).toEqual([
|
||||
{ limit: "10", page: "1" },
|
||||
{ limit: "10", page: "2" },
|
||||
{ limit: "10", page: "1", "filter-read_eq": "false" },
|
||||
{ limit: "10", page: "2", "filter-read_eq": "false" },
|
||||
{ limit: "10", page: "1", "filter-read_eq": "true" },
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps an in-flight multipage refetch on its original filter", async ({
|
||||
expect,
|
||||
}) => {
|
||||
let releasePage: () => void = () => undefined
|
||||
let reachedPage: () => void = () => undefined
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
reachedPage = resolve
|
||||
})
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releasePage = resolve
|
||||
})
|
||||
let hold = false
|
||||
const calls = mockEndpoint("GET", "/api/notifications", async (call) => {
|
||||
if (
|
||||
hold &&
|
||||
call.query.page === "1" &&
|
||||
call.query["filter-read_eq"] === undefined
|
||||
) {
|
||||
reachedPage()
|
||||
await release
|
||||
}
|
||||
|
||||
return makePage(
|
||||
[makeNotification(notificationPageId(call.query), false)],
|
||||
2,
|
||||
)
|
||||
})
|
||||
const api = makeNotificationAPI()
|
||||
const params = ref<NotificationsParams>({ limit: 10 })
|
||||
const list = runInApp(() => api.useFetchManyNotifications(params))
|
||||
await list.refresh()
|
||||
await list.loadNextPage()
|
||||
hold = true
|
||||
const pending = list.refetch()
|
||||
await reached
|
||||
|
||||
params.value = { limit: 10, read: false }
|
||||
await nextTick()
|
||||
await list.refresh()
|
||||
releasePage()
|
||||
await pending
|
||||
|
||||
expect(
|
||||
getPages(LIST_KEY_A)?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(["all-1", "all-2"])
|
||||
expect(
|
||||
list.data.value?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(["false-1"])
|
||||
expect(calls.map((call) => call.query)).toEqual([
|
||||
{ limit: "10", page: "1" },
|
||||
{ limit: "10", page: "2" },
|
||||
{ limit: "10", page: "1" },
|
||||
{ limit: "10", page: "1", "filter-read_eq": "false" },
|
||||
{ limit: "10", page: "2" },
|
||||
])
|
||||
})
|
||||
|
||||
it("refetches every loaded page in order", async ({ expect }) => {
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", (call) =>
|
||||
makePage([makeNotification(`n${String(call.query.page)}`, false)], 2),
|
||||
|
|
@ -159,6 +300,146 @@ describe("useNotificationAPI", { concurrent: false }, () => {
|
|||
})
|
||||
|
||||
describe("markNotificationsRead", () => {
|
||||
it("preserves the error when preparing the optimistic update fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const listCalls = mockEndpoint("GET", "/api/notifications", () =>
|
||||
makePage([]),
|
||||
)
|
||||
const countCalls = mockEndpoint(
|
||||
"GET",
|
||||
"/api/notifications/count",
|
||||
() => ({ count: 0 }),
|
||||
)
|
||||
const putCalls = mockEndpoint(
|
||||
"PUT",
|
||||
"/api/notifications/read-status",
|
||||
() => ({}),
|
||||
)
|
||||
const page = makePage([makeNotification("n1", false)])
|
||||
seedPages(LIST_KEY_A, page)
|
||||
const api = makeNotificationAPI()
|
||||
const queryCache = runInApp(() => useQueryCache())
|
||||
const error = new Error("Cache unavailable")
|
||||
const getQueryData = vi
|
||||
.spyOn(queryCache, "getQueryData")
|
||||
.mockImplementationOnce(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
api.markNotificationsRead.mutateAsync({ ids: ["n1"] }),
|
||||
).rejects.toBe(error)
|
||||
expect(getQueryData).toHaveBeenCalledExactlyOnceWith(LIST_KEY_A)
|
||||
expect(putCalls).toHaveLength(0)
|
||||
expect(listCalls).toHaveLength(0)
|
||||
expect(countCalls).toHaveLength(0)
|
||||
} finally {
|
||||
getQueryData.mockRestore()
|
||||
}
|
||||
|
||||
expect(getPages(LIST_KEY_A)).toEqual(makePages(page))
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: "one notification", ids: ["n1"], remaining: ["n2"] },
|
||||
{ name: "all notifications", ids: [], remaining: [] },
|
||||
])(
|
||||
"removes $name from unread pages while updating all pages",
|
||||
async ({ ids, remaining }, { expect }) => {
|
||||
const put = mockDeferredEndpoint(
|
||||
"PUT",
|
||||
"/api/notifications/read-status",
|
||||
)
|
||||
seedPages(
|
||||
LIST_KEY_A,
|
||||
makePage([
|
||||
makeNotification("n1", false),
|
||||
makeNotification("n2", false),
|
||||
]),
|
||||
)
|
||||
seedPages(
|
||||
UNREAD_KEY,
|
||||
makePage([makeNotification("n1", false)]),
|
||||
makePage([makeNotification("n2", false)]),
|
||||
)
|
||||
seedPages(READ_KEY, makePage([makeNotification("n3", true)]))
|
||||
const api = makeNotificationAPI()
|
||||
|
||||
const pending = api.markNotificationsRead.mutateAsync({ ids })
|
||||
await put.reached
|
||||
|
||||
expect(
|
||||
getPages(UNREAD_KEY)?.pages.flatMap((page) =>
|
||||
page.notifications.map((notification) => notification.id),
|
||||
),
|
||||
).toEqual(remaining)
|
||||
expect(readStates(LIST_KEY_A)).toEqual([[true, ids.length === 0]])
|
||||
expect(getPages(READ_KEY)).toEqual(
|
||||
makePages(makePage([makeNotification("n3", true)])),
|
||||
)
|
||||
expect(put.calls.map((call) => call.body)).toEqual([{ ids }])
|
||||
put.resolve({})
|
||||
await pending
|
||||
},
|
||||
)
|
||||
|
||||
it("restores unread page membership when marking read fails", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const put = mockDeferredEndpoint("PUT", "/api/notifications/read-status")
|
||||
const unreadPage = makePage([makeNotification("n1", false)])
|
||||
seedPages(LIST_KEY_A, unreadPage)
|
||||
seedPages(UNREAD_KEY, unreadPage)
|
||||
seedPages(READ_KEY, makePage([makeNotification("n2", true)]))
|
||||
const api = makeNotificationAPI()
|
||||
const pending = api.markNotificationsRead.mutateAsync({ ids: ["n1"] })
|
||||
await put.reached
|
||||
expect(getPages(UNREAD_KEY)?.pages[0]?.notifications).toEqual([])
|
||||
|
||||
put.reject(createError({ statusCode: 500 }))
|
||||
|
||||
await expect(pending).rejects.toThrow()
|
||||
expect(getPages(UNREAD_KEY)).toEqual(makePages(unreadPage))
|
||||
expect(getPages(LIST_KEY_A)).toEqual(makePages(unreadPage))
|
||||
expect(readStates(READ_KEY)).toEqual([[true]])
|
||||
expect(put.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("rolls back touched pages when a different filter loads during a failed mutation", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const unreadPage = makePage([makeNotification("n1", false)])
|
||||
const calls = mockEndpoint("GET", "/api/notifications", (call) =>
|
||||
call.query["filter-read_eq"] === "false" ? unreadPage : makePage([]),
|
||||
)
|
||||
const put = mockDeferredEndpoint("PUT", "/api/notifications/read-status")
|
||||
seedPages(LIST_KEY_A, unreadPage)
|
||||
const api = makeNotificationAPI()
|
||||
const params = ref<NotificationsParams>({ limit: 10, read: false })
|
||||
const list = runInApp(() => api.useFetchManyNotifications(params))
|
||||
await list.refresh()
|
||||
const pending = api.markNotificationsRead.mutateAsync({ ids: ["n1"] })
|
||||
await put.reached
|
||||
expect(getPages(UNREAD_KEY)?.pages[0]?.notifications).toEqual([])
|
||||
params.value = { limit: 10, read: true }
|
||||
await nextTick()
|
||||
await list.refresh()
|
||||
|
||||
put.reject(createError({ statusCode: 500 }))
|
||||
|
||||
await expect(pending).rejects.toThrow()
|
||||
expect(getPages(UNREAD_KEY)).toEqual(makePages(unreadPage))
|
||||
expect(getPages(LIST_KEY_A)).toEqual(makePages(unreadPage))
|
||||
expect(getPages(READ_KEY)).toEqual(makePages(makePage([])))
|
||||
expect(calls.map((call) => call.query["filter-read_eq"])).toEqual([
|
||||
"false",
|
||||
"true",
|
||||
])
|
||||
expect(put.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("marks every cached notification as read when no ids are given", async ({
|
||||
expect,
|
||||
}) => {
|
||||
|
|
@ -406,3 +687,10 @@ describe("useNotificationAPI", { concurrent: false }, () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
function notificationPageId(query: Record<string, unknown>) {
|
||||
const read = query["filter-read_eq"]
|
||||
const filter = typeof read === "string" ? read : "all"
|
||||
|
||||
return `${filter}-${String(query.page)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import isDeepEqual from "fast-deep-equal"
|
|||
type NotificationPages = UseInfiniteQueryData<NotificationsResponse, number>
|
||||
|
||||
const NOTIFICATION_QUERY_KEYS = {
|
||||
list: (limit: number) => ["notifications", "list", limit] as const,
|
||||
list: (limit: number, read?: boolean) =>
|
||||
["notifications", "list", limit, read ?? "all"] as const,
|
||||
count: (read: boolean) => ["notifications", "count", read] as const,
|
||||
listRoot: ["notifications", "list"] as const,
|
||||
countRoot: ["notifications", "count"] as const,
|
||||
|
|
@ -17,27 +18,35 @@ export default function () {
|
|||
function useFetchManyNotifications(
|
||||
paramsRef: MaybeRefOrGetter<NotificationsParams>,
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
key: () => NOTIFICATION_QUERY_KEYS.list(toValue(paramsRef).limit),
|
||||
query: async ({ pageParam }) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: String(toValue(paramsRef).limit),
|
||||
page: String(pageParam),
|
||||
})
|
||||
return useInfiniteQuery<NotificationsResponse, Error, number>(() => {
|
||||
const { limit, read } = toValue(paramsRef)
|
||||
|
||||
return await $coreAPIClient<NotificationsResponse>(
|
||||
`/api/notifications?${searchParams.toString()}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _pages, lastPageParam) =>
|
||||
lastPageParam < lastPage.pageCount ? lastPageParam + 1 : null,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 60 * 1000, // 1 min
|
||||
autoRefetch: true,
|
||||
return {
|
||||
key: NOTIFICATION_QUERY_KEYS.list(limit, read),
|
||||
query: async ({ pageParam }) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
page: String(pageParam),
|
||||
})
|
||||
|
||||
if (read !== undefined) {
|
||||
searchParams.set("filter-read_eq", String(read))
|
||||
}
|
||||
|
||||
return await $coreAPIClient<NotificationsResponse>(
|
||||
`/api/notifications?${searchParams.toString()}`,
|
||||
{ method: "GET" },
|
||||
)
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, _pages, lastPageParam) =>
|
||||
lastPageParam < lastPage.pageCount ? lastPageParam + 1 : null,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 60 * 1000, // 1 min
|
||||
autoRefetch: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +114,12 @@ export default function () {
|
|||
notif.read = true
|
||||
}
|
||||
})
|
||||
|
||||
if (key[3] === false) {
|
||||
page.notifications = page.notifications.filter(
|
||||
(notif) => !notif.read,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
queryCache.setQueryData(key, data)
|
||||
|
|
@ -119,17 +134,18 @@ export default function () {
|
|||
body: req,
|
||||
})
|
||||
},
|
||||
async onSuccess() {
|
||||
await queryCache.invalidateQueries({
|
||||
key: NOTIFICATION_QUERY_KEYS.listRoot,
|
||||
})
|
||||
await queryCache.invalidateQueries({
|
||||
key: NOTIFICATION_QUERY_KEYS.countRoot,
|
||||
})
|
||||
},
|
||||
onSuccess: invalidateNotifications,
|
||||
onError(_err, _data, { oldNotifs, newNotifs }) {
|
||||
if (!newNotifs) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = queryCache.getEntries({
|
||||
key: NOTIFICATION_QUERY_KEYS.listRoot,
|
||||
// switching filters can populate another cache while the
|
||||
// mutation is pending; it was not optimistically changed
|
||||
predicate: (entry) =>
|
||||
newNotifs.some(({ key }) => isDeepEqual(key, entry.key)),
|
||||
})
|
||||
const cachedNotifs: { key: EntryKey; data: NotificationPages }[] = []
|
||||
|
||||
|
|
@ -152,13 +168,21 @@ export default function () {
|
|||
}
|
||||
|
||||
// rollback
|
||||
oldNotifs?.forEach(({ key, data }) => {
|
||||
oldNotifs.forEach(({ key, data }) => {
|
||||
queryCache.setQueryData(key, data)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function invalidateNotifications() {
|
||||
await Promise.all([
|
||||
queryCache.invalidateQueries({ key: NOTIFICATION_QUERY_KEYS.listRoot }),
|
||||
queryCache.invalidateQueries({ key: NOTIFICATION_QUERY_KEYS.countRoot }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
invalidateNotifications,
|
||||
useFetchManyNotifications,
|
||||
useFetchNotificationCount,
|
||||
markNotificationsRead,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export const WS_NOTIFICATION_CREATION_TOPIC = "creation@notifications"
|
|||
|
||||
export interface NotificationsParams {
|
||||
limit: number
|
||||
read?: boolean
|
||||
}
|
||||
|
||||
export interface NotificationsCountParams {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@
|
|||
"read-all-button": "Mark all as read",
|
||||
"empty-title": "All caught up",
|
||||
"empty-description": "You'll receive notifications here for page review invitations, triggered hooks, and more",
|
||||
"filtered-empty-title": "No matching notifications",
|
||||
"filtered-empty-description": "Try a different filter",
|
||||
"filters": {
|
||||
"label": "Filter notifications",
|
||||
"all": "All",
|
||||
"unread": "Unread",
|
||||
"read": "Read"
|
||||
},
|
||||
"document-fallback": "Deleted Page",
|
||||
"hook-fallback": "Unknown",
|
||||
"days": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user