mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
feat(notifications): filter inbox by read status
Add All, Unread, and Read controls backed by the existing server filter.
Keep separate paginated query caches and reconcile mutations and live
updates across filters, including rollback after filter switches.
Adapt the All/Unread/Read UI approach proposed by adityawaghamare in
https://github.com/oxynote/oxynote/pull/203 (head
90b4fa6ad3). The current implementation
integrates server filtering and paginated caches on current main.
WIP transfer snapshot v1. Focused API and component tests, formatting,
and independent source review pass. Static, aggregate, coverage, and
end-to-end gates remain pending; this is not release-ready.
This commit is contained in:
parent
cf3bcb4403
commit
7e8f58f854
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
@ -215,6 +218,292 @@ 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) => ({
|
||||
notifications: [
|
||||
makeNotification({
|
||||
id: `${String(call.query["filter-read_eq"] ?? "all")}-${String(call.query.page)}`,
|
||||
}),
|
||||
],
|
||||
pageCount: call.query["filter-read_eq"] === "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 +831,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 +902,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 +943,9 @@ 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) => row.props("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,46 @@ 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") }}
|
||||
{{
|
||||
t(
|
||||
readFilter === "all"
|
||||
? "notification.empty-title"
|
||||
: "notification.filtered-empty-title",
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ t("notification.empty-description") }}
|
||||
{{
|
||||
t(
|
||||
readFilter === "all"
|
||||
? "notification.empty-description"
|
||||
: "notification.filtered-empty-description",
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 flex-1 flex-col">
|
||||
|
|
@ -270,7 +314,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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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,128 @@ 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(
|
||||
`${String(call.query["filter-read_eq"] ?? "all")}-${String(call.query.page)}`,
|
||||
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(
|
||||
`${String(call.query["filter-read_eq"] ?? "all")}-${String(call.query.page)}`,
|
||||
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 +305,104 @@ describe("useNotificationAPI", { concurrent: false }, () => {
|
|||
})
|
||||
|
||||
describe("markNotificationsRead", () => {
|
||||
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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
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,14 @@ 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 }) {
|
||||
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 }[] = []
|
||||
|
||||
|
|
@ -158,7 +170,15 @@ export default function () {
|
|||
},
|
||||
})
|
||||
|
||||
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