@open-mercato/ui 0.9.1-develop.7542.1.61a5e7dcbb → 0.9.1-develop.7543.1.4d3d24cb1e
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/backend/notifications/useNotificationActions.js +13 -2
- package/dist/backend/notifications/useNotificationActions.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/notifications/__tests__/useNotificationActions.test.tsx +86 -1
- package/src/backend/notifications/useNotificationActions.ts +19 -4
|
@@ -3,6 +3,9 @@ import * as React from "react";
|
|
|
3
3
|
import { apiCall, apiCallOrThrow } from "../utils/apiCall.js";
|
|
4
4
|
import { useGuardedMutation } from "../injection/useGuardedMutation.js";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
|
+
import {
|
|
7
|
+
NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
|
|
8
|
+
} from "@open-mercato/shared/modules/notifications/types";
|
|
6
9
|
const NOTIFICATION_ACTIONS_CONTEXT_ID = "notifications-actions";
|
|
7
10
|
function useNotificationActions(notifications, setNotifications, setUnreadCount) {
|
|
8
11
|
const markAsReadRef = React.useRef(async () => {
|
|
@@ -52,9 +55,17 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
52
55
|
)
|
|
53
56
|
);
|
|
54
57
|
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
58
|
+
} else if (result.status === 409 && result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE) {
|
|
59
|
+
const notification = notifications.find((n) => n.id === id);
|
|
60
|
+
setNotifications(
|
|
61
|
+
(prev) => prev.map((n) => n.id === id ? { ...n, status: "actioned" } : n)
|
|
62
|
+
);
|
|
63
|
+
if (notification?.status === "unread") {
|
|
64
|
+
setUnreadCount((prev) => Math.max(0, prev - 1));
|
|
65
|
+
}
|
|
55
66
|
}
|
|
56
67
|
return { href: result.result?.href };
|
|
57
|
-
}, [runMutation, retryLastMutation, setNotifications, setUnreadCount]);
|
|
68
|
+
}, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount]);
|
|
58
69
|
const dismiss = React.useCallback(
|
|
59
70
|
async (id) => {
|
|
60
71
|
await runMutation({
|
|
@@ -98,7 +109,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
|
|
|
98
109
|
const next = [
|
|
99
110
|
{
|
|
100
111
|
...dismissUndo.notification,
|
|
101
|
-
status: dismissUndo.previousStatus,
|
|
112
|
+
status: dismissUndo.notification.status === "actioned" ? "actioned" : dismissUndo.previousStatus,
|
|
102
113
|
readAt: dismissUndo.previousStatus === "unread" ? null : dismissUndo.notification.readAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
103
114
|
},
|
|
104
115
|
...prev.filter((n) => n.id !== dismissUndo.notification.id)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/useNotificationActions.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall, apiCallOrThrow } from '../utils/apiCall'\nimport { useGuardedMutation } from '../injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type
|
|
5
|
-
"mappings": ";AACA,YAAY,WAAW;AACvB,SAAS,SAAS,sBAAsB;AACxC,SAAS,0BAA0B;AACnC,SAAS,YAAY;
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall, apiCallOrThrow } from '../utils/apiCall'\nimport { useGuardedMutation } from '../injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,\n type NotificationDto,\n} from '@open-mercato/shared/modules/notifications/types'\n\nconst NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'\n\ntype NotificationMutationContext = {\n formId: string\n resourceKind: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport type NotificationDismissUndoState = {\n notification: NotificationDto\n previousStatus: 'read' | 'unread'\n} | null\n\ntype SetUnreadCount = React.Dispatch<React.SetStateAction<number>>\n\nexport type NotificationActionsResult = {\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: NotificationDismissUndoState\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n markAsReadRef: React.MutableRefObject<(id: string) => Promise<void>>\n dismissRef: React.MutableRefObject<(id: string) => Promise<void>>\n}\n\nexport function useNotificationActions(\n notifications: NotificationDto[],\n setNotifications: React.Dispatch<React.SetStateAction<NotificationDto[]>>,\n setUnreadCount: SetUnreadCount,\n): NotificationActionsResult {\n const markAsReadRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const dismissRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)\n const dismissUndoTimerRef = React.useRef<number | null>(null)\n\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<NotificationMutationContext>({\n contextId: NOTIFICATION_ACTIONS_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const markAsRead = React.useCallback(async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n markAsReadRef.current = markAsRead\n }, [markAsRead])\n\n const executeAction = React.useCallback(async (id: string, actionId: string) => {\n const result = await runMutation({\n operation: () =>\n apiCall<{ ok?: boolean; href?: string; code?: string }>(\n `/api/notifications/${id}/action`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ actionId }),\n },\n ),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id, actionId },\n })\n\n if (result.ok) {\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'actioned', actionTaken: actionId } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n } else if (\n result.status === 409 &&\n result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE\n ) {\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'actioned' } : n)),\n )\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n }\n\n return { href: result.result?.href }\n }, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n const dismiss = React.useCallback(\n async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n if (notification) {\n const previousStatus = notification.status === 'unread' ? 'unread' : 'read'\n setDismissUndo({ notification, previousStatus })\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n dismissUndoTimerRef.current = window.setTimeout(() => {\n setDismissUndo(null)\n }, 6000)\n }\n },\n [notifications, setNotifications, setUnreadCount, runMutation, retryLastMutation],\n )\n\n React.useEffect(() => {\n dismissRef.current = dismiss\n }, [dismiss])\n\n const undoDismiss = React.useCallback(async () => {\n if (!dismissUndo) return\n await runMutation({\n operation: () =>\n apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: dismissUndo.previousStatus }),\n }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id: dismissUndo.notification.id, status: dismissUndo.previousStatus },\n })\n\n setNotifications((prev) => {\n const next = [\n {\n ...dismissUndo.notification,\n status:\n dismissUndo.notification.status === 'actioned' ? 'actioned' : dismissUndo.previousStatus,\n readAt:\n dismissUndo.previousStatus === 'unread'\n ? null\n : dismissUndo.notification.readAt ?? new Date().toISOString(),\n },\n ...prev.filter((n) => n.id !== dismissUndo.notification.id),\n ]\n return next.sort(\n (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n )\n })\n\n if (dismissUndo.previousStatus === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n setDismissUndo(null)\n }, [dismissUndo, setNotifications, setUnreadCount, runMutation, retryLastMutation])\n\n const markAllRead = React.useCallback(async () => {\n await runMutation({\n operation: () => apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: {},\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.status === 'unread'\n ? { ...n, status: 'read', readAt: new Date().toISOString() }\n : n,\n ),\n )\n setUnreadCount(0)\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n return () => {\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n }\n }, [])\n\n return {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7543.1.4d3d24cb1e",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7543.1.4d3d24cb1e",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7543.1.4d3d24cb1e",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -32,7 +32,7 @@ type RunMutationInput = {
|
|
|
32
32
|
mutationPayload: Record<string, unknown>
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function makeNotification(id: string, status: '
|
|
35
|
+
function makeNotification(id: string, status: NotificationDto['status'] = 'unread'): NotificationDto {
|
|
36
36
|
return {
|
|
37
37
|
id,
|
|
38
38
|
type: 'example',
|
|
@@ -134,6 +134,91 @@ describe('useNotificationActions guarded mutations', () => {
|
|
|
134
134
|
)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
+
it.each([
|
|
138
|
+
['actioned', 'actioned'],
|
|
139
|
+
['read', 'read'],
|
|
140
|
+
['unread', 'unread'],
|
|
141
|
+
] as const)('puts an undone %s notification back as %s', async (status, restoredStatus) => {
|
|
142
|
+
const setNotifications = jest.fn()
|
|
143
|
+
const setUnreadCount = jest.fn()
|
|
144
|
+
const notification = { ...makeNotification('n1', status), readAt: status === 'unread' ? null : '2026-01-01T10:00:00.000Z' }
|
|
145
|
+
const { result } = renderHook(() =>
|
|
146
|
+
useNotificationActions([notification], setNotifications, setUnreadCount),
|
|
147
|
+
)
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.dismiss('n1')
|
|
150
|
+
})
|
|
151
|
+
setNotifications.mockClear()
|
|
152
|
+
await act(async () => {
|
|
153
|
+
await result.current.undoDismiss()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
expect(setNotifications).toHaveBeenCalledTimes(1)
|
|
157
|
+
const restore = setNotifications.mock.calls[0][0] as (prev: NotificationDto[]) => NotificationDto[]
|
|
158
|
+
const [restored] = restore([])
|
|
159
|
+
expect(restored.id).toBe('n1')
|
|
160
|
+
expect(restored.status).toBe(restoredStatus)
|
|
161
|
+
expect(restored.readAt ?? null).toBe(notification.readAt)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('marks the local notification actioned when the server reports the action already ran', async () => {
|
|
165
|
+
apiCallMock.mockResolvedValue({
|
|
166
|
+
ok: false,
|
|
167
|
+
status: 409,
|
|
168
|
+
result: { error: 'Notification action already executed', code: 'notification_action_already_executed' },
|
|
169
|
+
})
|
|
170
|
+
const setNotifications = jest.fn()
|
|
171
|
+
const setUnreadCount = jest.fn()
|
|
172
|
+
const notification = makeNotification('n1', 'read')
|
|
173
|
+
const { result } = renderHook(() =>
|
|
174
|
+
useNotificationActions([notification], setNotifications, setUnreadCount),
|
|
175
|
+
)
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await result.current.executeAction('n1', 'approve')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(setNotifications).toHaveBeenCalledTimes(1)
|
|
181
|
+
const update = setNotifications.mock.calls[0][0] as (prev: NotificationDto[]) => NotificationDto[]
|
|
182
|
+
expect(update([notification, makeNotification('n2', 'read')]).map((n) => n.status)).toEqual(['actioned', 'read'])
|
|
183
|
+
expect(setUnreadCount).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('decrements the unread count when the server reports an unread notification\'s action already ran', async () => {
|
|
187
|
+
apiCallMock.mockResolvedValue({
|
|
188
|
+
ok: false,
|
|
189
|
+
status: 409,
|
|
190
|
+
result: { error: 'Notification action already executed', code: 'notification_action_already_executed' },
|
|
191
|
+
})
|
|
192
|
+
const setNotifications = jest.fn()
|
|
193
|
+
const setUnreadCount = jest.fn()
|
|
194
|
+
const { result } = renderHook(() =>
|
|
195
|
+
useNotificationActions([makeNotification('n1', 'unread')], setNotifications, setUnreadCount),
|
|
196
|
+
)
|
|
197
|
+
await act(async () => {
|
|
198
|
+
await result.current.executeAction('n1', 'approve')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
expect(setUnreadCount).toHaveBeenCalledTimes(1)
|
|
202
|
+
const decrement = setUnreadCount.mock.calls[0][0] as (prev: number) => number
|
|
203
|
+
expect(decrement(3)).toBe(2)
|
|
204
|
+
expect(decrement(0)).toBe(0)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('leaves the local notification alone when the action command itself answers 409', async () => {
|
|
208
|
+
apiCallMock.mockResolvedValue({ ok: false, status: 409, result: { error: 'Order was changed by someone else' } })
|
|
209
|
+
const setNotifications = jest.fn()
|
|
210
|
+
const setUnreadCount = jest.fn()
|
|
211
|
+
const { result } = renderHook(() =>
|
|
212
|
+
useNotificationActions([makeNotification('n1', 'read')], setNotifications, setUnreadCount),
|
|
213
|
+
)
|
|
214
|
+
await act(async () => {
|
|
215
|
+
await result.current.executeAction('n1', 'approve')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
expect(setNotifications).not.toHaveBeenCalled()
|
|
219
|
+
expect(setUnreadCount).not.toHaveBeenCalled()
|
|
220
|
+
})
|
|
221
|
+
|
|
137
222
|
it('routes markAllRead through the guarded mutation path', async () => {
|
|
138
223
|
const { result } = renderActions([makeNotification('n1')])
|
|
139
224
|
await act(async () => {
|
|
@@ -3,7 +3,10 @@ import * as React from 'react'
|
|
|
3
3
|
import { apiCall, apiCallOrThrow } from '../utils/apiCall'
|
|
4
4
|
import { useGuardedMutation } from '../injection/useGuardedMutation'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,
|
|
8
|
+
type NotificationDto,
|
|
9
|
+
} from '@open-mercato/shared/modules/notifications/types'
|
|
7
10
|
|
|
8
11
|
const NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'
|
|
9
12
|
|
|
@@ -68,7 +71,7 @@ export function useNotificationActions(
|
|
|
68
71
|
const executeAction = React.useCallback(async (id: string, actionId: string) => {
|
|
69
72
|
const result = await runMutation({
|
|
70
73
|
operation: () =>
|
|
71
|
-
apiCall<{ ok
|
|
74
|
+
apiCall<{ ok?: boolean; href?: string; code?: string }>(
|
|
72
75
|
`/api/notifications/${id}/action`,
|
|
73
76
|
{
|
|
74
77
|
method: 'POST',
|
|
@@ -87,10 +90,21 @@ export function useNotificationActions(
|
|
|
87
90
|
),
|
|
88
91
|
)
|
|
89
92
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
|
93
|
+
} else if (
|
|
94
|
+
result.status === 409 &&
|
|
95
|
+
result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
|
|
96
|
+
) {
|
|
97
|
+
const notification = notifications.find((n) => n.id === id)
|
|
98
|
+
setNotifications((prev) =>
|
|
99
|
+
prev.map((n) => (n.id === id ? { ...n, status: 'actioned' } : n)),
|
|
100
|
+
)
|
|
101
|
+
if (notification?.status === 'unread') {
|
|
102
|
+
setUnreadCount((prev) => Math.max(0, prev - 1))
|
|
103
|
+
}
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
return { href: result.result?.href }
|
|
93
|
-
}, [runMutation, retryLastMutation, setNotifications, setUnreadCount])
|
|
107
|
+
}, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount])
|
|
94
108
|
|
|
95
109
|
const dismiss = React.useCallback(
|
|
96
110
|
async (id: string) => {
|
|
@@ -139,7 +153,8 @@ export function useNotificationActions(
|
|
|
139
153
|
const next = [
|
|
140
154
|
{
|
|
141
155
|
...dismissUndo.notification,
|
|
142
|
-
status:
|
|
156
|
+
status:
|
|
157
|
+
dismissUndo.notification.status === 'actioned' ? 'actioned' : dismissUndo.previousStatus,
|
|
143
158
|
readAt:
|
|
144
159
|
dismissUndo.previousStatus === 'unread'
|
|
145
160
|
? null
|