@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.
Files changed (61) hide show
  1. package/dist/{BlockNoteEditor-7VMBZI3V.js → BlockNoteEditor-2M2M7MT4.js} +19 -19
  2. package/dist/{BlockNoteEditor-7VMBZI3V.js.map → BlockNoteEditor-2M2M7MT4.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-FX5RSUPA.mjs → BlockNoteEditor-FKYWM4NJ.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-SVVAZ6TQ.js → BlockNoteViewer-XCGBX4ST.js} +3 -3
  5. package/dist/{BlockNoteViewer-SVVAZ6TQ.js.map → BlockNoteViewer-XCGBX4ST.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-RK2RKOB7.mjs → BlockNoteViewer-ZY4ZF3HO.mjs} +2 -2
  7. package/dist/billing/index.js +401 -401
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-YCFPSXLK.mjs → chunk-CK47KSFV.mjs} +6 -1
  10. package/dist/{chunk-YCFPSXLK.mjs.map → chunk-CK47KSFV.mjs.map} +1 -1
  11. package/dist/{chunk-GNXZNUQV.mjs → chunk-HP3SPOPP.mjs} +1723 -1482
  12. package/dist/chunk-HP3SPOPP.mjs.map +1 -0
  13. package/dist/{chunk-TBSMAL5Y.js → chunk-MWZCCOTS.js} +6 -1
  14. package/dist/chunk-MWZCCOTS.js.map +1 -0
  15. package/dist/{chunk-ODH2LYWC.js → chunk-SYQMVTIC.js} +7 -7
  16. package/dist/{chunk-ODH2LYWC.js.map → chunk-SYQMVTIC.js.map} +1 -1
  17. package/dist/{chunk-IALPADOD.mjs → chunk-U55JQ4NY.mjs} +2 -2
  18. package/dist/{chunk-RT3TM342.js → chunk-XMYHR2EF.js} +1243 -1002
  19. package/dist/chunk-XMYHR2EF.js.map +1 -0
  20. package/dist/client/index.js +4 -4
  21. package/dist/client/index.mjs +3 -3
  22. package/dist/components/index.d.mts +24 -2
  23. package/dist/components/index.d.ts +24 -2
  24. package/dist/components/index.js +8 -4
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/components/index.mjs +7 -3
  27. package/dist/contexts/index.js +4 -4
  28. package/dist/contexts/index.mjs +3 -3
  29. package/dist/core/index.d.mts +3 -0
  30. package/dist/core/index.d.ts +3 -0
  31. package/dist/core/index.js +2 -2
  32. package/dist/core/index.mjs +1 -1
  33. package/dist/features/help/index.js +37 -37
  34. package/dist/features/help/index.mjs +3 -3
  35. package/dist/features/tokenusage/index.js +101 -101
  36. package/dist/features/tokenusage/index.mjs +3 -3
  37. package/dist/index.js +3 -3
  38. package/dist/index.mjs +2 -2
  39. package/dist/server/index.js +3 -3
  40. package/dist/server/index.mjs +1 -1
  41. package/package.json +1 -1
  42. package/src/components/tables/ContentListTable.tsx +32 -2
  43. package/src/features/notification/components/containers/NotificationsListContainer.tsx +35 -18
  44. package/src/features/notification/components/filters/NotificationViewSwitcher.tsx +48 -0
  45. package/src/features/notification/components/filters/index.ts +1 -0
  46. package/src/features/notification/components/index.ts +1 -0
  47. package/src/features/notification/components/lists/NotificationsList.tsx +59 -104
  48. package/src/features/notification/components/modals/NotificationModal.tsx +97 -27
  49. package/src/features/notification/components/modals/__tests__/NotificationModal.spec.tsx +15 -4
  50. package/src/features/notification/components/notifications/Notification.tsx +19 -14
  51. package/src/features/notification/components/notifications/NotificationIcon.tsx +37 -0
  52. package/src/features/notification/components/notifications/index.ts +1 -0
  53. package/src/features/notification/data/notification.fields.ts +4 -0
  54. package/src/features/notification/hooks/useNotificationTableStructure.tsx +186 -0
  55. package/src/features/notification/notification.module.ts +2 -0
  56. package/dist/chunk-GNXZNUQV.mjs.map +0 -1
  57. package/dist/chunk-RT3TM342.js.map +0 -1
  58. package/dist/chunk-TBSMAL5Y.js.map +0 -1
  59. /package/dist/{BlockNoteEditor-FX5RSUPA.mjs.map → BlockNoteEditor-FKYWM4NJ.mjs.map} +0 -0
  60. /package/dist/{BlockNoteViewer-RK2RKOB7.mjs.map → BlockNoteViewer-ZY4ZF3HO.mjs.map} +0 -0
  61. /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
- Card,
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
- <PopoverTrigger>
188
- <SidebarMenuButton render={<div />} className="text-muted-foreground h-6" disabled={isLoading}>
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={`h-5 w-5 cursor-pointer ${unreadNotifications ? "text-destructive" : ""} ${isLoading ? "animate-pulse" : ""}`}
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
- {t(`entities.notifications`, { count: 2 })}
193
- </SidebarMenuButton>
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
- <PopoverContent className="relative start-10 w-80 border-0 p-0 shadow-none">
196
- <Card>
197
- <CardHeader className="p-4">
198
- <CardTitle>{t(`entities.notifications`, { count: 2 })}</CardTitle>
199
- {isLoading && <div className="text-muted-foreground text-xs">Loading...</div>}
200
- {error && <div className="text-destructive text-xs/relaxed">Error: {error}</div>}
201
- </CardHeader>
202
- <Separator />
203
- <ScrollArea className="h-96">
204
- {notifications.length > 0 ? (
205
- notifications.map((notification: NotificationInterface) => (
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
- <div className="text-muted-foreground p-4 text-center text-sm">{t(`notification.empty`)}</div>
210
- )}
211
- </ScrollArea>
212
- </Card>
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
- Card: passthrough,
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
- <div className={`flex w-full flex-row p-2 ${isRead ? "" : "bg-muted"} items-center`}>
97
- {data.actor ? (
98
- <div className="flex w-12 max-w-12 px-2">
99
- <UserAvatar user={data.actor} className="h-8 w-8" />
100
- </div>
101
- ) : (
102
- <div className="flex w-14 max-w-14 px-2"></div>
103
- )}
104
- <div className="flex w-full flex-col">
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-1 w-full text-xs">
114
- {new Date(notification.createdAt).toLocaleString()}
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
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./Notification";
2
2
  export * from "./PushNotificationProvider";
3
+ export * from "./NotificationIcon";
@@ -3,6 +3,10 @@ export enum NotificationFields {
3
3
 
4
4
  name = "name",
5
5
 
6
+ icon = "icon",
7
+ description = "description",
8
+ actions = "actions",
9
+
6
10
  createdAt = "createdAt",
7
11
  updatedAt = "updatedAt",
8
12
  }
@@ -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
  });