@carlonicora/nextjs-jsonapi 3.19.1 → 3.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-7VMBZI3V.js → BlockNoteEditor-2M2M7MT4.js} +19 -19
- package/dist/{BlockNoteEditor-7VMBZI3V.js.map → BlockNoteEditor-2M2M7MT4.js.map} +1 -1
- package/dist/{BlockNoteEditor-FX5RSUPA.mjs → BlockNoteEditor-FKYWM4NJ.mjs} +4 -4
- package/dist/{BlockNoteViewer-SVVAZ6TQ.js → BlockNoteViewer-XCGBX4ST.js} +3 -3
- package/dist/{BlockNoteViewer-SVVAZ6TQ.js.map → BlockNoteViewer-XCGBX4ST.js.map} +1 -1
- package/dist/{BlockNoteViewer-RK2RKOB7.mjs → BlockNoteViewer-ZY4ZF3HO.mjs} +2 -2
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-YCFPSXLK.mjs → chunk-CK47KSFV.mjs} +6 -1
- package/dist/{chunk-YCFPSXLK.mjs.map → chunk-CK47KSFV.mjs.map} +1 -1
- package/dist/{chunk-GNXZNUQV.mjs → chunk-HP3SPOPP.mjs} +1723 -1482
- package/dist/chunk-HP3SPOPP.mjs.map +1 -0
- package/dist/{chunk-TBSMAL5Y.js → chunk-MWZCCOTS.js} +6 -1
- package/dist/chunk-MWZCCOTS.js.map +1 -0
- package/dist/{chunk-ODH2LYWC.js → chunk-SYQMVTIC.js} +7 -7
- package/dist/{chunk-ODH2LYWC.js.map → chunk-SYQMVTIC.js.map} +1 -1
- package/dist/{chunk-IALPADOD.mjs → chunk-U55JQ4NY.mjs} +2 -2
- package/dist/{chunk-RT3TM342.js → chunk-XMYHR2EF.js} +1243 -1002
- package/dist/chunk-XMYHR2EF.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +24 -2
- package/dist/components/index.d.ts +24 -2
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +3 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/tables/ContentListTable.tsx +32 -2
- package/src/features/notification/components/containers/NotificationsListContainer.tsx +35 -18
- package/src/features/notification/components/filters/NotificationViewSwitcher.tsx +48 -0
- package/src/features/notification/components/filters/index.ts +1 -0
- package/src/features/notification/components/index.ts +1 -0
- package/src/features/notification/components/lists/NotificationsList.tsx +59 -104
- package/src/features/notification/components/modals/NotificationModal.tsx +97 -27
- package/src/features/notification/components/modals/__tests__/NotificationModal.spec.tsx +15 -4
- package/src/features/notification/components/notifications/Notification.tsx +19 -14
- package/src/features/notification/components/notifications/NotificationIcon.tsx +37 -0
- package/src/features/notification/components/notifications/index.ts +1 -0
- package/src/features/notification/data/notification.fields.ts +4 -0
- package/src/features/notification/hooks/useNotificationTableStructure.tsx +186 -0
- package/src/features/notification/notification.module.ts +2 -0
- package/dist/chunk-GNXZNUQV.mjs.map +0 -1
- package/dist/chunk-RT3TM342.js.map +0 -1
- package/dist/chunk-TBSMAL5Y.js.map +0 -1
- /package/dist/{BlockNoteEditor-FX5RSUPA.mjs.map → BlockNoteEditor-FKYWM4NJ.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-RK2RKOB7.mjs.map → BlockNoteViewer-ZY4ZF3HO.mjs.map} +0 -0
- /package/dist/{chunk-IALPADOD.mjs.map → chunk-U55JQ4NY.mjs.map} +0 -0
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import { BellIcon } from "lucide-react";
|
|
1
|
+
import { BellIcon, ExternalLinkIcon } from "lucide-react";
|
|
2
2
|
import { useTranslations } from "next-intl";
|
|
3
3
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { showToast } from "../../../../utils/toast";
|
|
5
|
+
import { cn } from "../../../../utils";
|
|
5
6
|
import { useSocketContext } from "../../../../contexts";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
6
8
|
import { usePageUrlGenerator } from "../../../../hooks";
|
|
7
9
|
import {
|
|
8
|
-
|
|
9
|
-
CardHeader,
|
|
10
|
-
CardTitle,
|
|
10
|
+
Link,
|
|
11
11
|
Popover,
|
|
12
12
|
PopoverContent,
|
|
13
|
+
PopoverHeader,
|
|
14
|
+
PopoverTitle,
|
|
13
15
|
PopoverTrigger,
|
|
14
16
|
ScrollArea,
|
|
15
|
-
Separator,
|
|
16
17
|
SidebarMenuButton,
|
|
18
|
+
Tooltip,
|
|
19
|
+
TooltipContent,
|
|
20
|
+
TooltipTrigger,
|
|
17
21
|
} from "../../../../shadcnui";
|
|
18
22
|
import { useNotificationContext } from "../../contexts/NotificationContext";
|
|
19
23
|
import { NotificationInterface } from "../../data";
|
|
@@ -184,32 +188,98 @@ function NotificationModalContent({ isOpen, setIsOpen }: NotificationModalProps)
|
|
|
184
188
|
|
|
185
189
|
return (
|
|
186
190
|
<Popover open={isOpen} onOpenChange={handleOpenChange} data-testid={`sidebar-notification button`}>
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
{/* The badge overhangs the bell's top-end corner, so nothing between it
|
|
192
|
+
and the sidebar may clip: `overflow-visible` beats the button
|
|
193
|
+
variant's own `overflow-hidden`, and the label is wrapped in its own
|
|
194
|
+
span so the variant's `[&>span:last-child]:truncate` (which sets
|
|
195
|
+
overflow:hidden) lands on the label rather than on the icon slot. */}
|
|
196
|
+
<PopoverTrigger
|
|
197
|
+
render={<SidebarMenuButton className="text-muted-foreground h-6 overflow-visible" disabled={isLoading} />}
|
|
198
|
+
>
|
|
199
|
+
<span className="relative flex shrink-0 items-center justify-center overflow-visible">
|
|
189
200
|
<BellIcon
|
|
190
|
-
className={
|
|
201
|
+
className={cn(
|
|
202
|
+
"h-5 w-5 cursor-pointer",
|
|
203
|
+
// Filled, not just tinted: an outline bell in the danger colour
|
|
204
|
+
// is easy to miss at this size, a solid one is not.
|
|
205
|
+
unreadNotifications && "fill-destructive text-destructive",
|
|
206
|
+
isLoading && "animate-pulse",
|
|
207
|
+
)}
|
|
191
208
|
/>
|
|
192
|
-
{
|
|
193
|
-
|
|
209
|
+
{unreadNotifications && (
|
|
210
|
+
// Fixed px, not rem: the app's root font size is 14px, so a
|
|
211
|
+
// rem-sized badge renders a quarter smaller than the scale implies
|
|
212
|
+
// and the count stops being readable. The ring separates the dot
|
|
213
|
+
// from the bell it sits on.
|
|
214
|
+
<span
|
|
215
|
+
data-testid="sidebar-notification-unread-dot"
|
|
216
|
+
className="bg-destructive ring-sidebar pointer-events-none absolute -top-[3px] -end-[4px] size-[9px] rounded-full ring-2 group-data-[collapsible=icon]:block md:hidden"
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
</span>
|
|
220
|
+
<span className="truncate">{t(`entities.notifications`, { count: 2 })}</span>
|
|
221
|
+
{unreadNotifications && (
|
|
222
|
+
<span
|
|
223
|
+
data-testid="sidebar-notification-unread-count"
|
|
224
|
+
className="bg-destructive text-destructive-foreground pointer-events-none ms-auto flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full px-[5px] text-[11px] leading-none font-semibold tabular-nums group-data-[collapsible=icon]:hidden"
|
|
225
|
+
>
|
|
226
|
+
{unreadCount > 99 ? "99+" : unreadCount}
|
|
227
|
+
</span>
|
|
228
|
+
)}
|
|
194
229
|
</PopoverTrigger>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
230
|
+
{/* The popup IS the surface — a Card inside it stacked two paddings
|
|
231
|
+
(Card py-4 + CardHeader p-4) around a 20px title, which is why the
|
|
232
|
+
header was so tall. Widths are in a 14px rem here, so the previous
|
|
233
|
+
w-80 measured 280px on screen, not 320; 30rem = 420px. */}
|
|
234
|
+
<PopoverContent className="relative start-10 w-[30rem] gap-0 overflow-hidden p-0">
|
|
235
|
+
<PopoverHeader className="flex flex-row items-center justify-between gap-2 border-b px-3 py-2">
|
|
236
|
+
{/* Typography role 4 (section header) rather than role 5 (panel
|
|
237
|
+
title): the popover reads as a mini-page, not a card slot. */}
|
|
238
|
+
<PopoverTitle className="text-lg font-semibold">{t(`entities.notifications`, { count: 2 })}</PopoverTitle>
|
|
239
|
+
<Tooltip>
|
|
240
|
+
<TooltipTrigger
|
|
241
|
+
render={
|
|
242
|
+
<Link
|
|
243
|
+
href={generateUrl({ page: Modules.Notification })}
|
|
244
|
+
onClick={() => setIsOpen(false)}
|
|
245
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
246
|
+
/>
|
|
247
|
+
}
|
|
248
|
+
>
|
|
249
|
+
<ExternalLinkIcon className="size-4" />
|
|
250
|
+
</TooltipTrigger>
|
|
251
|
+
<TooltipContent>{t(`notification.all`)}</TooltipContent>
|
|
252
|
+
</Tooltip>
|
|
253
|
+
</PopoverHeader>
|
|
254
|
+
{isLoading && <div className="text-muted-foreground px-3 py-2 text-xs">{t(`common.loading`)}</div>}
|
|
255
|
+
{error && <div className="text-destructive px-3 py-2 text-xs">{error}</div>}
|
|
256
|
+
{/* The cap goes on the scroll viewport, not on the ScrollArea root: the
|
|
257
|
+
viewport carries Base UI's inline overflow and h-full, so a height
|
|
258
|
+
on the root leaves it stretching to its content instead of
|
|
259
|
+
scrolling.
|
|
260
|
+
It is measured against --available-height (set by the positioner,
|
|
261
|
+
after any flip) minus the header, the same way the package combobox
|
|
262
|
+
sizes its list. A fixed cap lets the popup outgrow the room below
|
|
263
|
+
the trigger, and floating-ui's shift() then clamps it into the
|
|
264
|
+
viewport — which is why it landed in the same spot wherever the
|
|
265
|
+
trigger sat, and never scrolled. */}
|
|
266
|
+
{/* The ScrollBar styles itself with data-vertical:w-2.5, but Base UI
|
|
267
|
+
emits data-orientation="vertical" — no data-vertical attribute
|
|
268
|
+
exists, so the track renders 2px wide with a 0px-wide thumb: an
|
|
269
|
+
invisible scrollbar. Forced here by data-slot until the primitive is
|
|
270
|
+
fixed. The thumb's bg-border is also invisible against the popup,
|
|
271
|
+
hence the explicit colour. */}
|
|
272
|
+
<ScrollArea className="[&_[data-slot=scroll-area-scrollbar]]:w-2.5 [&_[data-slot=scroll-area-thumb]]:bg-muted-foreground/40 [&>[data-slot=scroll-area-viewport]]:max-h-[min(32rem,calc(var(--available-height,32rem)-3.5rem))]">
|
|
273
|
+
{notifications.length > 0 ? (
|
|
274
|
+
<div className="divide-border divide-y">
|
|
275
|
+
{notifications.map((notification: NotificationInterface) => (
|
|
206
276
|
<Fragment key={notification.id}>{generateNotification(notification, () => setIsOpen(false))}</Fragment>
|
|
207
|
-
))
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)}
|
|
211
|
-
|
|
212
|
-
</
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
) : (
|
|
280
|
+
<div className="text-muted-foreground p-6 text-center text-sm">{t(`notification.empty`)}</div>
|
|
281
|
+
)}
|
|
282
|
+
</ScrollArea>
|
|
213
283
|
</PopoverContent>
|
|
214
284
|
</Popover>
|
|
215
285
|
);
|
|
@@ -46,6 +46,15 @@ vi.mock("../../../../../contexts", () =>
|
|
|
46
46
|
);
|
|
47
47
|
|
|
48
48
|
vi.mock("../../../../../hooks", () => mocks.barrel({ usePageUrlGenerator: () => (p: unknown) => String(p) }));
|
|
49
|
+
|
|
50
|
+
// `Modules` is a Proxy that throws for any module the app has not registered,
|
|
51
|
+
// and the "view all" link reads `Modules.Notification` while rendering. Only
|
|
52
|
+
// that key is replaced — the rest of the barrel stays real, because the data
|
|
53
|
+
// layer imported alongside it still extends `AbstractApiData`.
|
|
54
|
+
vi.mock("../../../../../core", async () => {
|
|
55
|
+
const actual = await vi.importActual<Record<string, unknown>>("../../../../../core");
|
|
56
|
+
return { ...actual, Modules: { Notification: { name: "notifications", pageUrl: "/notifications" } } };
|
|
57
|
+
});
|
|
49
58
|
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
50
59
|
|
|
51
60
|
vi.mock("../../../contexts/NotificationContext", () => ({
|
|
@@ -66,15 +75,17 @@ vi.mock("../../../contexts/NotificationContext", () => ({
|
|
|
66
75
|
vi.mock("../../../../../shadcnui", () => {
|
|
67
76
|
const passthrough = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
|
|
68
77
|
return mocks.barrel({
|
|
69
|
-
|
|
70
|
-
CardHeader: passthrough,
|
|
71
|
-
CardTitle: passthrough,
|
|
78
|
+
Link: passthrough,
|
|
72
79
|
Popover: passthrough,
|
|
73
80
|
PopoverContent: passthrough,
|
|
81
|
+
PopoverHeader: passthrough,
|
|
82
|
+
PopoverTitle: passthrough,
|
|
74
83
|
PopoverTrigger: passthrough,
|
|
75
84
|
ScrollArea: passthrough,
|
|
76
|
-
Separator: () => null,
|
|
77
85
|
SidebarMenuButton: passthrough,
|
|
86
|
+
Tooltip: passthrough,
|
|
87
|
+
TooltipContent: passthrough,
|
|
88
|
+
TooltipTrigger: passthrough,
|
|
78
89
|
});
|
|
79
90
|
});
|
|
80
91
|
|
|
@@ -4,9 +4,11 @@ import { useTranslations } from "next-intl";
|
|
|
4
4
|
import { ReactElement, useEffect, useState } from "react";
|
|
5
5
|
import { usePageUrlGenerator } from "../../../../hooks";
|
|
6
6
|
import { Link } from "../../../../shadcnui";
|
|
7
|
+
import { formatDate } from "../../../../utils";
|
|
7
8
|
import { UserInterface } from "../../../user";
|
|
8
9
|
import { UserAvatar } from "../../../user/components";
|
|
9
10
|
import { NotificationInterface } from "../../data";
|
|
11
|
+
import { NotificationIcon } from "./NotificationIcon";
|
|
10
12
|
|
|
11
13
|
type TaskCommentedOnProps = {
|
|
12
14
|
notification: NotificationInterface;
|
|
@@ -93,34 +95,37 @@ export function NotificationMenuItem({ notification, closePopover }: TaskComment
|
|
|
93
95
|
const data = generateNotificationData({ notification: notification, generateUrl: generateUrl });
|
|
94
96
|
|
|
95
97
|
const response = (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<p className="text-sm">
|
|
98
|
+
/* Unread is marked with a tinted row + dot, not the muted background the
|
|
99
|
+
row used to carry: grey-on-grey reads as disabled, the opposite of
|
|
100
|
+
"needs your attention". */
|
|
101
|
+
<div
|
|
102
|
+
className={`hover:bg-accent flex w-full flex-row items-start gap-3 px-3 py-2.5 transition-colors ${isRead ? "" : "bg-primary/5"}`}
|
|
103
|
+
>
|
|
104
|
+
<NotificationIcon notificationType={notification.notificationType} />
|
|
105
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
106
|
+
<p className="text-foreground text-sm">
|
|
106
107
|
{t.rich(`notification.${notification.notificationType}.description` as any, {
|
|
107
|
-
strong: (chunks: any) => <strong>{chunks}</strong>,
|
|
108
|
+
strong: (chunks: any) => <strong className="font-medium">{chunks}</strong>,
|
|
108
109
|
actor: data.actor?.name ?? "",
|
|
109
110
|
title: data.title,
|
|
110
111
|
message: notification.message ?? "",
|
|
111
112
|
})}
|
|
112
113
|
</p>
|
|
113
|
-
<div className="text-muted-foreground mt-
|
|
114
|
-
{new Date(notification.createdAt)
|
|
114
|
+
<div className="text-muted-foreground mt-0.5 text-xs">
|
|
115
|
+
{formatDate(new Date(notification.createdAt), "default")}
|
|
115
116
|
</div>
|
|
116
117
|
</div>
|
|
118
|
+
{!isRead && <span className="bg-primary mt-1.5 size-2 shrink-0 rounded-full" aria-hidden />}
|
|
117
119
|
</div>
|
|
118
120
|
);
|
|
119
121
|
|
|
120
122
|
if (!data.url) return response;
|
|
121
123
|
|
|
124
|
+
/* The whole row is the click target, so the package Link's text-primary
|
|
125
|
+
font-medium (typography role 14 — inline navigational text) would paint
|
|
126
|
+
every notification blue. Emphasis inside the sentence comes from weight. */
|
|
122
127
|
return (
|
|
123
|
-
<Link href={data.url} onClick={closePopover}>
|
|
128
|
+
<Link href={data.url} onClick={closePopover} className="text-foreground block font-normal">
|
|
124
129
|
{response}
|
|
125
130
|
</Link>
|
|
126
131
|
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AudioLinesIcon, BellIcon, LucideIcon } from "lucide-react";
|
|
4
|
+
import { cn } from "../../../../utils";
|
|
5
|
+
|
|
6
|
+
type IconTone = "muted" | "warning" | "destructive";
|
|
7
|
+
|
|
8
|
+
/* Every row carries a glyph in the same 32px circle, which keeps the left rail
|
|
9
|
+
aligned and gives the eye a faster channel than reading the sentence. Types
|
|
10
|
+
not listed here fall back to the bell. */
|
|
11
|
+
const notificationIcons: Record<string, { icon: LucideIcon; tone: IconTone }> = {
|
|
12
|
+
transcript_ready: { icon: AudioLinesIcon, tone: "muted" },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const toneClasses: Record<IconTone, string> = {
|
|
16
|
+
muted: "bg-muted text-muted-foreground",
|
|
17
|
+
warning: "bg-warning/10 text-warning",
|
|
18
|
+
destructive: "bg-destructive/10 text-destructive",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type NotificationIconProps = {
|
|
22
|
+
notificationType: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function NotificationIcon({ notificationType, className }: NotificationIconProps) {
|
|
27
|
+
const { icon: Icon, tone } = notificationIcons[notificationType] ?? { icon: BellIcon, tone: "muted" as IconTone };
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className={cn("flex size-8 shrink-0 items-center justify-center rounded-full", toneClasses[tone], className)}
|
|
32
|
+
aria-hidden
|
|
33
|
+
>
|
|
34
|
+
<Icon className="size-4" />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { ArchiveIcon } from "lucide-react";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
|
|
8
|
+
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "../../../shadcnui";
|
|
9
|
+
import { formatDate } from "../../../utils";
|
|
10
|
+
import { generateNotificationData } from "../components/notifications/Notification";
|
|
11
|
+
import { useNotificationContext } from "../contexts/NotificationContext";
|
|
12
|
+
import { NotificationIcon } from "../components/notifications/NotificationIcon";
|
|
13
|
+
import { NotificationInterface } from "../data";
|
|
14
|
+
import { NotificationFields } from "../data/notification.fields";
|
|
15
|
+
import { NotificationService } from "../data/notification.service";
|
|
16
|
+
|
|
17
|
+
const NOTIFICATIONS_MODULE_NAME = "notifications";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Columns for the /notifications list.
|
|
21
|
+
*
|
|
22
|
+
* The row shape deliberately mirrors the notification popover row
|
|
23
|
+
* (`Notification.tsx` → `NotificationMenuItem`): the same `NotificationIcon` in
|
|
24
|
+
* a fixed first column, the same `t.rich(...)` sentence with `font-medium`
|
|
25
|
+
* emphasis, the same timestamp. Someone moving from the bell to the full page
|
|
26
|
+
* should recognise the same rows, not a different rendering of them.
|
|
27
|
+
*
|
|
28
|
+
* `context.archived` comes from `ContentListTable`'s `context` prop — the
|
|
29
|
+
* archive control is hidden in the archived tab, where it has nothing to do.
|
|
30
|
+
*/
|
|
31
|
+
export const useNotificationTableStructure: UseTableStructureHook<NotificationInterface, NotificationFields> = (
|
|
32
|
+
params,
|
|
33
|
+
) => {
|
|
34
|
+
const t = useTranslations();
|
|
35
|
+
const generateUrl = usePageUrlGenerator();
|
|
36
|
+
const { loadNotifications } = useNotificationContext();
|
|
37
|
+
|
|
38
|
+
const archived: boolean = params.context?.archived === true;
|
|
39
|
+
|
|
40
|
+
/* The shared context is refetched, not just this list's retriever: the bell
|
|
41
|
+
renders from that context, and the API already hides archived rows from
|
|
42
|
+
it. Dropping the row only here left the popover showing a notification the
|
|
43
|
+
page had just archived until the context's 5-minute cache expired. */
|
|
44
|
+
const archiveNotification = async (notification: NotificationInterface) => {
|
|
45
|
+
await NotificationService.archive({ id: notification.id });
|
|
46
|
+
params.dataRetriever?.removeElement(notification);
|
|
47
|
+
await loadNotifications();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const tableData = useMemo(() => {
|
|
51
|
+
return params.data.map((notification: NotificationInterface) => {
|
|
52
|
+
const entry: TableContent<NotificationInterface> = { jsonApiData: notification };
|
|
53
|
+
entry[NotificationFields.notificationId] = notification.id;
|
|
54
|
+
entry[NotificationFields.createdAt] = notification.createdAt;
|
|
55
|
+
return entry;
|
|
56
|
+
});
|
|
57
|
+
}, [params.data]);
|
|
58
|
+
|
|
59
|
+
const fieldColumnMap: Partial<Record<NotificationFields, () => any>> = {
|
|
60
|
+
[NotificationFields.icon]: () => ({
|
|
61
|
+
id: "icon",
|
|
62
|
+
accessorKey: "icon",
|
|
63
|
+
header: ``,
|
|
64
|
+
meta: { className: "w-20" },
|
|
65
|
+
cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
|
|
66
|
+
const notification: NotificationInterface = row.original.jsonApiData;
|
|
67
|
+
|
|
68
|
+
/* `isRead` here is the value the row was FETCHED with, and it stays
|
|
69
|
+
that way: opening the page marks what it shows as read, so a dot
|
|
70
|
+
that reacted to that would blink out under the reader's eyes. It
|
|
71
|
+
means "was unread when you opened the page", and it leads the row so
|
|
72
|
+
the eye picks up what is new before reading anything else. */
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
{notification.isRead ? (
|
|
76
|
+
<span className="size-2 shrink-0" aria-hidden />
|
|
77
|
+
) : (
|
|
78
|
+
<Tooltip>
|
|
79
|
+
<TooltipTrigger render={<span className="bg-primary size-2 shrink-0 rounded-full" />}>
|
|
80
|
+
<span className="sr-only">{t(`notification.unread`)}</span>
|
|
81
|
+
</TooltipTrigger>
|
|
82
|
+
<TooltipContent>{t(`notification.unread`)}</TooltipContent>
|
|
83
|
+
</Tooltip>
|
|
84
|
+
)}
|
|
85
|
+
<NotificationIcon notificationType={notification.notificationType} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
enableSorting: false,
|
|
90
|
+
enableHiding: false,
|
|
91
|
+
}),
|
|
92
|
+
[NotificationFields.description]: () => ({
|
|
93
|
+
id: "description",
|
|
94
|
+
accessorKey: "description",
|
|
95
|
+
header: t(`notification.description`),
|
|
96
|
+
cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
|
|
97
|
+
const notification: NotificationInterface = row.original.jsonApiData;
|
|
98
|
+
const data = generateNotificationData({ notification: notification, generateUrl: generateUrl });
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<p className="text-foreground text-sm">
|
|
102
|
+
{t.rich(`notification.${notification.notificationType}.description` as any, {
|
|
103
|
+
strong: (chunks: any) => <strong className="font-medium">{chunks}</strong>,
|
|
104
|
+
actor: data.actor?.name ?? "",
|
|
105
|
+
title: data.title,
|
|
106
|
+
message: notification.message ?? "",
|
|
107
|
+
})}
|
|
108
|
+
</p>
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
enableSorting: false,
|
|
112
|
+
enableHiding: false,
|
|
113
|
+
}),
|
|
114
|
+
[NotificationFields.createdAt]: () => ({
|
|
115
|
+
id: "createdAt",
|
|
116
|
+
accessorKey: "createdAt",
|
|
117
|
+
header: t(`notification.createdAt`),
|
|
118
|
+
meta: { className: "w-40" },
|
|
119
|
+
cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
|
|
120
|
+
const notification: NotificationInterface = row.original.jsonApiData;
|
|
121
|
+
|
|
122
|
+
/* Not `cellDate`: that renders the absolute shape, and the popover row
|
|
123
|
+
shows how long ago it arrived. The two must match. */
|
|
124
|
+
return (
|
|
125
|
+
<span className="text-muted-foreground text-xs">
|
|
126
|
+
{formatDate(new Date(notification.createdAt), "default")}
|
|
127
|
+
</span>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
enableSorting: false,
|
|
131
|
+
enableHiding: false,
|
|
132
|
+
}),
|
|
133
|
+
[NotificationFields.actions]: () => ({
|
|
134
|
+
id: "actions",
|
|
135
|
+
accessorKey: "actions",
|
|
136
|
+
header: ``,
|
|
137
|
+
meta: { className: "w-16" },
|
|
138
|
+
cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
|
|
139
|
+
const notification: NotificationInterface = row.original.jsonApiData;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex flex-row items-center justify-end gap-x-1">
|
|
143
|
+
{!archived && (
|
|
144
|
+
<Tooltip>
|
|
145
|
+
<TooltipTrigger
|
|
146
|
+
render={
|
|
147
|
+
<Button
|
|
148
|
+
variant={`link`}
|
|
149
|
+
data-testid="notification-archive-button"
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
archiveNotification(notification);
|
|
154
|
+
}}
|
|
155
|
+
className="text-muted-foreground hover:text-destructive"
|
|
156
|
+
/>
|
|
157
|
+
}
|
|
158
|
+
>
|
|
159
|
+
<ArchiveIcon className="h-4 w-4 cursor-pointer" />
|
|
160
|
+
</TooltipTrigger>
|
|
161
|
+
<TooltipContent>{t(`notification.buttons.archive`)}</TooltipContent>
|
|
162
|
+
</Tooltip>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
},
|
|
167
|
+
enableSorting: false,
|
|
168
|
+
enableHiding: false,
|
|
169
|
+
}),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const columns = useMemo(() => {
|
|
173
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
174
|
+
TableContent<NotificationInterface>
|
|
175
|
+
>[];
|
|
176
|
+
}, [params.fields, fieldColumnMap, t, generateUrl]);
|
|
177
|
+
|
|
178
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/* Registered by module NAME, not by `Modules.Notification`: `Modules` is a
|
|
182
|
+
Proxy that throws for any module the app has not bootstrapped yet, and this
|
|
183
|
+
line runs while the module graph is still being evaluated — before
|
|
184
|
+
`configureJsonApi` has had a chance to register anything. The lookup in
|
|
185
|
+
`useTableGenerator` keys off `module.name`, so the two are equivalent. */
|
|
186
|
+
registerTableGenerator(NOTIFICATIONS_MODULE_NAME, useNotificationTableStructure);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BellIcon } from "lucide-react";
|
|
1
2
|
import { ModuleFactory } from "../../permissions";
|
|
2
3
|
import { Notification } from "./data/notification";
|
|
3
4
|
|
|
@@ -6,5 +7,6 @@ export const NotificationModule = (factory: ModuleFactory) =>
|
|
|
6
7
|
pageUrl: "/notifications",
|
|
7
8
|
name: "notifications",
|
|
8
9
|
model: Notification,
|
|
10
|
+
icon: BellIcon,
|
|
9
11
|
moduleId: "9259d704-c670-4e77-a3a1-a728ffc5be3d",
|
|
10
12
|
});
|