@open-mercato/ui 0.6.6-develop.6205.1.109e4b6a84 → 0.6.6-develop.6227.1.2695efff30

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.
@@ -1,6 +1,9 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { apiCall, apiCallOrThrow } from "../utils/apiCall.js";
4
+ import { useGuardedMutation } from "../injection/useGuardedMutation.js";
5
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
6
+ const NOTIFICATION_ACTIONS_CONTEXT_ID = "notifications-actions";
4
7
  function useNotificationActions(notifications, setNotifications, setUnreadCount) {
5
8
  const markAsReadRef = React.useRef(async () => {
6
9
  });
@@ -8,27 +11,40 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
8
11
  });
9
12
  const [dismissUndo, setDismissUndo] = React.useState(null);
10
13
  const dismissUndoTimerRef = React.useRef(null);
14
+ const t = useT();
15
+ const { runMutation, retryLastMutation } = useGuardedMutation({
16
+ contextId: NOTIFICATION_ACTIONS_CONTEXT_ID,
17
+ blockedMessage: t("ui.forms.flash.saveBlocked", "Save blocked by validation")
18
+ });
11
19
  const markAsRead = React.useCallback(async (id) => {
12
- await apiCallOrThrow(`/api/notifications/${id}/read`, { method: "PUT" });
20
+ await runMutation({
21
+ operation: () => apiCallOrThrow(`/api/notifications/${id}/read`, { method: "PUT" }),
22
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: "notification", retryLastMutation },
23
+ mutationPayload: { id }
24
+ });
13
25
  setNotifications(
14
26
  (prev) => prev.map(
15
27
  (n) => n.id === id ? { ...n, status: "read", readAt: (/* @__PURE__ */ new Date()).toISOString() } : n
16
28
  )
17
29
  );
18
30
  setUnreadCount((prev) => Math.max(0, prev - 1));
19
- }, [setNotifications, setUnreadCount]);
31
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount]);
20
32
  React.useEffect(() => {
21
33
  markAsReadRef.current = markAsRead;
22
34
  }, [markAsRead]);
23
35
  const executeAction = React.useCallback(async (id, actionId) => {
24
- const result = await apiCall(
25
- `/api/notifications/${id}/action`,
26
- {
27
- method: "POST",
28
- headers: { "content-type": "application/json" },
29
- body: JSON.stringify({ actionId })
30
- }
31
- );
36
+ const result = await runMutation({
37
+ operation: () => apiCall(
38
+ `/api/notifications/${id}/action`,
39
+ {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json" },
42
+ body: JSON.stringify({ actionId })
43
+ }
44
+ ),
45
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: "notification", retryLastMutation },
46
+ mutationPayload: { id, actionId }
47
+ });
32
48
  if (result.ok) {
33
49
  setNotifications(
34
50
  (prev) => prev.map(
@@ -38,10 +54,14 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
38
54
  setUnreadCount((prev) => Math.max(0, prev - 1));
39
55
  }
40
56
  return { href: result.result?.href };
41
- }, [setNotifications, setUnreadCount]);
57
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount]);
42
58
  const dismiss = React.useCallback(
43
59
  async (id) => {
44
- await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: "PUT" });
60
+ await runMutation({
61
+ operation: () => apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: "PUT" }),
62
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: "notification", retryLastMutation },
63
+ mutationPayload: { id }
64
+ });
45
65
  const notification = notifications.find((n) => n.id === id);
46
66
  setNotifications((prev) => prev.filter((n) => n.id !== id));
47
67
  if (notification?.status === "unread") {
@@ -58,17 +78,21 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
58
78
  }, 6e3);
59
79
  }
60
80
  },
61
- [notifications, setNotifications, setUnreadCount]
81
+ [notifications, setNotifications, setUnreadCount, runMutation, retryLastMutation]
62
82
  );
63
83
  React.useEffect(() => {
64
84
  dismissRef.current = dismiss;
65
85
  }, [dismiss]);
66
86
  const undoDismiss = React.useCallback(async () => {
67
87
  if (!dismissUndo) return;
68
- await apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
69
- method: "PUT",
70
- headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ status: dismissUndo.previousStatus })
88
+ await runMutation({
89
+ operation: () => apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
90
+ method: "PUT",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({ status: dismissUndo.previousStatus })
93
+ }),
94
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: "notification", retryLastMutation },
95
+ mutationPayload: { id: dismissUndo.notification.id, status: dismissUndo.previousStatus }
72
96
  });
73
97
  setNotifications((prev) => {
74
98
  const next = [
@@ -90,16 +114,20 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
90
114
  window.clearTimeout(dismissUndoTimerRef.current);
91
115
  }
92
116
  setDismissUndo(null);
93
- }, [dismissUndo, setNotifications, setUnreadCount]);
117
+ }, [dismissUndo, setNotifications, setUnreadCount, runMutation, retryLastMutation]);
94
118
  const markAllRead = React.useCallback(async () => {
95
- await apiCallOrThrow("/api/notifications/mark-all-read", { method: "PUT" });
119
+ await runMutation({
120
+ operation: () => apiCallOrThrow("/api/notifications/mark-all-read", { method: "PUT" }),
121
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: "notification", retryLastMutation },
122
+ mutationPayload: {}
123
+ });
96
124
  setNotifications(
97
125
  (prev) => prev.map(
98
126
  (n) => n.status === "unread" ? { ...n, status: "read", readAt: (/* @__PURE__ */ new Date()).toISOString() } : n
99
127
  )
100
128
  );
101
129
  setUnreadCount(0);
102
- }, [setNotifications, setUnreadCount]);
130
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount]);
103
131
  React.useEffect(() => {
104
132
  return () => {
105
133
  if (dismissUndoTimerRef.current) {
@@ -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 type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\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 markAsRead = React.useCallback(async (id: string) => {\n await apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' })\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 }, [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 apiCall<{ ok: boolean; href?: string }>(\n `/api/notifications/${id}/action`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ actionId }),\n },\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 }\n\n return { href: result.result?.href }\n }, [setNotifications, setUnreadCount])\n\n const dismiss = React.useCallback(\n async (id: string) => {\n await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' })\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],\n )\n\n React.useEffect(() => {\n dismissRef.current = dismiss\n }, [dismiss])\n\n const undoDismiss = React.useCallback(async () => {\n if (!dismissUndo) return\n await 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\n setNotifications((prev) => {\n const next = [\n {\n ...dismissUndo.notification,\n status: 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])\n\n const markAllRead = React.useCallback(async () => {\n await apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' })\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 }, [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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",
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 { NotificationDto } 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 }>(\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 }\n\n return { href: result.result?.href }\n }, [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: 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
  }
@@ -4,6 +4,9 @@ import * as React from "react";
4
4
  import { cn } from "@open-mercato/shared/lib/utils";
5
5
  import { Input } from "./input.js";
6
6
  import { Tag } from "./tag.js";
7
+ function defaultRemoveTagLabel(tag) {
8
+ return `Remove ${tag}`;
9
+ }
7
10
  function escapeRegExp(input) {
8
11
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9
12
  }
@@ -29,7 +32,8 @@ const TagInput = React.forwardRef(
29
32
  name,
30
33
  "aria-label": ariaLabel,
31
34
  "aria-invalid": ariaInvalid,
32
- rightIcon
35
+ rightIcon,
36
+ removeTagLabel = defaultRemoveTagLabel
33
37
  }, ref) => {
34
38
  const [internalValue, setInternalValue] = React.useState([]);
35
39
  const [inputText, setInputText] = React.useState("");
@@ -160,7 +164,7 @@ const TagInput = React.forwardRef(
160
164
  shape: "square",
161
165
  disabled,
162
166
  onRemove: () => removeTagAt(index),
163
- removeAriaLabel: `Remove ${tag}`,
167
+ removeAriaLabel: removeTagLabel(tag),
164
168
  children: tag
165
169
  },
166
170
  `${tag}-${index}`
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/tag-input.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { Input, type InputProps } from './input'\nimport { Tag } from './tag'\n\nexport type TagInputProps = {\n value?: string[]\n onChange?: (value: string[]) => void\n placeholder?: string\n size?: 'sm' | 'default' | 'lg'\n disabled?: boolean\n maxTags?: number\n validate?: (tag: string) => boolean | string\n separator?: string | RegExp\n allowDuplicates?: boolean\n className?: string\n id?: string\n name?: string\n 'aria-label'?: string\n 'aria-invalid'?: boolean\n /**\n * Optional right-side icon for the input row (e.g. info-circle, search). Mirrors\n * the Figma `Tag Input` Filled state with `Right Icon=true`.\n */\n rightIcon?: InputProps['rightIcon']\n}\n\nfunction escapeRegExp(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nfunction buildSplitter(separator: string | RegExp): RegExp {\n if (separator instanceof RegExp) {\n return separator.flags.includes('g')\n ? separator\n : new RegExp(separator.source, `${separator.flags}g`)\n }\n return new RegExp(escapeRegExp(separator), 'g')\n}\n\nexport const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(\n (\n {\n value: valueProp,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n maxTags,\n validate,\n separator = ',',\n allowDuplicates = false,\n className,\n id,\n name,\n 'aria-label': ariaLabel,\n 'aria-invalid': ariaInvalid,\n rightIcon,\n },\n ref,\n ) => {\n const [internalValue, setInternalValue] = React.useState<string[]>([])\n const [inputText, setInputText] = React.useState('')\n const [error, setError] = React.useState<string | null>(null)\n\n const isControlled = valueProp !== undefined\n const value = isControlled ? (valueProp as string[]) : internalValue\n\n const commitValue = React.useCallback(\n (next: string[]) => {\n if (!isControlled) setInternalValue(next)\n onChange?.(next)\n },\n [isControlled, onChange],\n )\n\n const appendToList = React.useCallback(\n (current: string[], rawTag: string): { next: string[]; errorText: string | null } | null => {\n const tag = rawTag.trim()\n if (!tag) return null\n if (!allowDuplicates && current.includes(tag)) return null\n if (typeof maxTags === 'number' && current.length >= maxTags) return null\n if (validate) {\n const result = validate(tag)\n if (result === false) return null\n if (typeof result === 'string') return { next: current, errorText: result }\n }\n return { next: [...current, tag], errorText: null }\n },\n [allowDuplicates, maxTags, validate],\n )\n\n const tryAddTag = React.useCallback(\n (rawTag: string): boolean => {\n const outcome = appendToList(value, rawTag)\n if (!outcome) return false\n if (outcome.errorText !== null) {\n setError(outcome.errorText)\n return false\n }\n setError(null)\n if (outcome.next !== value) commitValue(outcome.next)\n return outcome.next !== value\n },\n [appendToList, commitValue, value],\n )\n\n const tryAddManyTags = React.useCallback(\n (rawTags: string[]) => {\n let acc = value\n let pendingError: string | null = null\n for (const raw of rawTags) {\n const outcome = appendToList(acc, raw)\n if (!outcome) continue\n if (outcome.errorText !== null) {\n pendingError = outcome.errorText\n continue\n }\n acc = outcome.next\n }\n if (acc !== value) commitValue(acc)\n if (pendingError !== null) setError(pendingError)\n else if (acc !== value) setError(null)\n },\n [appendToList, commitValue, value],\n )\n\n const removeTagAt = React.useCallback(\n (index: number) => {\n if (index < 0 || index >= value.length) return\n const next = value.slice(0, index).concat(value.slice(index + 1))\n commitValue(next)\n setError(null)\n },\n [commitValue, value],\n )\n\n const splitter = React.useMemo(() => buildSplitter(separator), [separator])\n\n const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n const text = event.target.value\n splitter.lastIndex = 0\n if (splitter.test(text)) {\n splitter.lastIndex = 0\n const parts = text.split(splitter)\n const trailing = parts.pop() ?? ''\n tryAddManyTags(parts)\n setInputText(trailing)\n return\n }\n setInputText(text)\n if (error) setError(null)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n tryAddTag(inputText)\n setInputText('')\n return\n }\n if (event.key === 'Backspace' && inputText.length === 0 && value.length > 0) {\n event.preventDefault()\n removeTagAt(value.length - 1)\n }\n }\n\n const limitReached = typeof maxTags === 'number' && value.length >= maxTags\n\n return (\n <div className={cn('flex w-full flex-col gap-1', className)} data-slot=\"tag-input\">\n <Input\n ref={ref}\n type=\"text\"\n size={size}\n id={id}\n name={name}\n value={inputText}\n onChange={handleChange}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n disabled={disabled || limitReached}\n aria-label={ariaLabel}\n aria-invalid={ariaInvalid || Boolean(error) || undefined}\n rightIcon={rightIcon}\n data-slot=\"tag-input-field\"\n />\n {value.length > 0 ? (\n <div\n className=\"flex flex-wrap items-center gap-2 pt-1\"\n data-slot=\"tag-input-chips\"\n >\n {value.map((tag, index) => (\n <Tag\n key={`${tag}-${index}`}\n variant=\"default\"\n shape=\"square\"\n disabled={disabled}\n onRemove={() => removeTagAt(index)}\n removeAriaLabel={`Remove ${tag}`}\n >\n {tag}\n </Tag>\n ))}\n </div>\n ) : null}\n {error ? (\n <p\n className=\"text-xs text-status-error-text\"\n role=\"alert\"\n data-slot=\"tag-input-error\"\n >\n {error}\n </p>\n ) : null}\n </div>\n )\n },\n)\n\nTagInput.displayName = 'TagInput'\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { Input, type InputProps } from './input'\nimport { Tag } from './tag'\n\nexport type TagInputProps = {\n value?: string[]\n onChange?: (value: string[]) => void\n placeholder?: string\n size?: 'sm' | 'default' | 'lg'\n disabled?: boolean\n maxTags?: number\n validate?: (tag: string) => boolean | string\n separator?: string | RegExp\n allowDuplicates?: boolean\n className?: string\n id?: string\n name?: string\n 'aria-label'?: string\n 'aria-invalid'?: boolean\n /**\n * Optional right-side icon for the input row (e.g. info-circle, search). Mirrors\n * the Figma `Tag Input` Filled state with `Right Icon=true`.\n */\n rightIcon?: InputProps['rightIcon']\n /**\n * Builds the accessible label for each tag's remove (\u00D7) button. Lets callers\n * localize the action; defaults to the English `Remove {tag}`.\n */\n removeTagLabel?: (tag: string) => string\n}\n\nfunction defaultRemoveTagLabel(tag: string): string {\n return `Remove ${tag}`\n}\n\nfunction escapeRegExp(input: string): string {\n return input.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\nfunction buildSplitter(separator: string | RegExp): RegExp {\n if (separator instanceof RegExp) {\n return separator.flags.includes('g')\n ? separator\n : new RegExp(separator.source, `${separator.flags}g`)\n }\n return new RegExp(escapeRegExp(separator), 'g')\n}\n\nexport const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(\n (\n {\n value: valueProp,\n onChange,\n placeholder,\n size = 'default',\n disabled = false,\n maxTags,\n validate,\n separator = ',',\n allowDuplicates = false,\n className,\n id,\n name,\n 'aria-label': ariaLabel,\n 'aria-invalid': ariaInvalid,\n rightIcon,\n removeTagLabel = defaultRemoveTagLabel,\n },\n ref,\n ) => {\n const [internalValue, setInternalValue] = React.useState<string[]>([])\n const [inputText, setInputText] = React.useState('')\n const [error, setError] = React.useState<string | null>(null)\n\n const isControlled = valueProp !== undefined\n const value = isControlled ? (valueProp as string[]) : internalValue\n\n const commitValue = React.useCallback(\n (next: string[]) => {\n if (!isControlled) setInternalValue(next)\n onChange?.(next)\n },\n [isControlled, onChange],\n )\n\n const appendToList = React.useCallback(\n (current: string[], rawTag: string): { next: string[]; errorText: string | null } | null => {\n const tag = rawTag.trim()\n if (!tag) return null\n if (!allowDuplicates && current.includes(tag)) return null\n if (typeof maxTags === 'number' && current.length >= maxTags) return null\n if (validate) {\n const result = validate(tag)\n if (result === false) return null\n if (typeof result === 'string') return { next: current, errorText: result }\n }\n return { next: [...current, tag], errorText: null }\n },\n [allowDuplicates, maxTags, validate],\n )\n\n const tryAddTag = React.useCallback(\n (rawTag: string): boolean => {\n const outcome = appendToList(value, rawTag)\n if (!outcome) return false\n if (outcome.errorText !== null) {\n setError(outcome.errorText)\n return false\n }\n setError(null)\n if (outcome.next !== value) commitValue(outcome.next)\n return outcome.next !== value\n },\n [appendToList, commitValue, value],\n )\n\n const tryAddManyTags = React.useCallback(\n (rawTags: string[]) => {\n let acc = value\n let pendingError: string | null = null\n for (const raw of rawTags) {\n const outcome = appendToList(acc, raw)\n if (!outcome) continue\n if (outcome.errorText !== null) {\n pendingError = outcome.errorText\n continue\n }\n acc = outcome.next\n }\n if (acc !== value) commitValue(acc)\n if (pendingError !== null) setError(pendingError)\n else if (acc !== value) setError(null)\n },\n [appendToList, commitValue, value],\n )\n\n const removeTagAt = React.useCallback(\n (index: number) => {\n if (index < 0 || index >= value.length) return\n const next = value.slice(0, index).concat(value.slice(index + 1))\n commitValue(next)\n setError(null)\n },\n [commitValue, value],\n )\n\n const splitter = React.useMemo(() => buildSplitter(separator), [separator])\n\n const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n const text = event.target.value\n splitter.lastIndex = 0\n if (splitter.test(text)) {\n splitter.lastIndex = 0\n const parts = text.split(splitter)\n const trailing = parts.pop() ?? ''\n tryAddManyTags(parts)\n setInputText(trailing)\n return\n }\n setInputText(text)\n if (error) setError(null)\n }\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n tryAddTag(inputText)\n setInputText('')\n return\n }\n if (event.key === 'Backspace' && inputText.length === 0 && value.length > 0) {\n event.preventDefault()\n removeTagAt(value.length - 1)\n }\n }\n\n const limitReached = typeof maxTags === 'number' && value.length >= maxTags\n\n return (\n <div className={cn('flex w-full flex-col gap-1', className)} data-slot=\"tag-input\">\n <Input\n ref={ref}\n type=\"text\"\n size={size}\n id={id}\n name={name}\n value={inputText}\n onChange={handleChange}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n disabled={disabled || limitReached}\n aria-label={ariaLabel}\n aria-invalid={ariaInvalid || Boolean(error) || undefined}\n rightIcon={rightIcon}\n data-slot=\"tag-input-field\"\n />\n {value.length > 0 ? (\n <div\n className=\"flex flex-wrap items-center gap-2 pt-1\"\n data-slot=\"tag-input-chips\"\n >\n {value.map((tag, index) => (\n <Tag\n key={`${tag}-${index}`}\n variant=\"default\"\n shape=\"square\"\n disabled={disabled}\n onRemove={() => removeTagAt(index)}\n removeAriaLabel={removeTagLabel(tag)}\n >\n {tag}\n </Tag>\n ))}\n </div>\n ) : null}\n {error ? (\n <p\n className=\"text-xs text-status-error-text\"\n role=\"alert\"\n data-slot=\"tag-input-error\"\n >\n {error}\n </p>\n ) : null}\n </div>\n )\n },\n)\n\nTagInput.displayName = 'TagInput'\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.6.6-develop.6205.1.109e4b6a84",
3
+ "version": "0.6.6-develop.6227.1.2695efff30",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.6-develop.6205.1.109e4b6a84",
158
+ "@open-mercato/shared": "0.6.6-develop.6227.1.2695efff30",
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
- "@open-mercato/shared": "0.6.6-develop.6205.1.109e4b6a84",
164
+ "@open-mercato/shared": "0.6.6-develop.6227.1.2695efff30",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -5,9 +5,14 @@ jest.mock('../../utils/api', () => ({
5
5
  import { act, renderHook } from '@testing-library/react'
6
6
  import * as React from 'react'
7
7
  import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
9
  import { apiFetch } from '../../utils/api'
9
10
  import { useNotificationActions } from '../useNotificationActions'
10
11
 
12
+ function I18nWrapper({ children }: { children: React.ReactNode }) {
13
+ return React.createElement(I18nProvider, { locale: 'en', dict: {} }, children)
14
+ }
15
+
11
16
  function makeNotification(id: string): NotificationDto {
12
17
  return {
13
18
  id,
@@ -39,8 +44,9 @@ function setFetchResult(status: number) {
39
44
  function renderActions(notifications: NotificationDto[]) {
40
45
  const setNotifications = jest.fn()
41
46
  const setUnreadCount = jest.fn()
42
- const hook = renderHook(() =>
43
- useNotificationActions(notifications, setNotifications, setUnreadCount),
47
+ const hook = renderHook(
48
+ () => useNotificationActions(notifications, setNotifications, setUnreadCount),
49
+ { wrapper: I18nWrapper },
44
50
  )
45
51
  return { hook, setNotifications, setUnreadCount }
46
52
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { act, renderHook } from '@testing-library/react'
5
+ import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
6
+
7
+ const apiCallMock = jest.fn()
8
+ const apiCallOrThrowMock = jest.fn()
9
+ const runMutationMock = jest.fn()
10
+ const retryLastMutation = jest.fn(async () => true)
11
+
12
+ jest.mock('../../utils/apiCall', () => ({
13
+ apiCall: (...args: unknown[]) => apiCallMock(...args),
14
+ apiCallOrThrow: (...args: unknown[]) => apiCallOrThrowMock(...args),
15
+ }))
16
+
17
+ jest.mock('../../injection/useGuardedMutation', () => ({
18
+ useGuardedMutation: () => ({
19
+ runMutation: (...args: unknown[]) => runMutationMock(...args),
20
+ retryLastMutation,
21
+ }),
22
+ }))
23
+
24
+ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
25
+ useT: () => (key: string, fallback?: string) => fallback ?? key,
26
+ }))
27
+
28
+ import { useNotificationActions } from '../useNotificationActions'
29
+
30
+ type RunMutationInput = {
31
+ context: { resourceKind: string; retryLastMutation: () => Promise<boolean> }
32
+ mutationPayload: Record<string, unknown>
33
+ }
34
+
35
+ function makeNotification(id: string, status: 'unread' | 'read' = 'unread'): NotificationDto {
36
+ return {
37
+ id,
38
+ type: 'example',
39
+ title: `title-${id}`,
40
+ severity: 'info',
41
+ status,
42
+ actions: [],
43
+ createdAt: new Date().toISOString(),
44
+ }
45
+ }
46
+
47
+ function renderActions(initial: NotificationDto[] = []) {
48
+ const setNotifications = jest.fn()
49
+ const setUnreadCount = jest.fn()
50
+ const { result } = renderHook(() =>
51
+ useNotificationActions(initial, setNotifications, setUnreadCount),
52
+ )
53
+ return { result }
54
+ }
55
+
56
+ function lastRunMutationInput(): RunMutationInput {
57
+ return runMutationMock.mock.calls[runMutationMock.mock.calls.length - 1][0] as RunMutationInput
58
+ }
59
+
60
+ describe('useNotificationActions guarded mutations', () => {
61
+ beforeEach(() => {
62
+ apiCallMock.mockReset()
63
+ apiCallMock.mockResolvedValue({ ok: true, result: {} })
64
+ apiCallOrThrowMock.mockReset()
65
+ apiCallOrThrowMock.mockResolvedValue({ ok: true, result: {} })
66
+ runMutationMock.mockReset()
67
+ runMutationMock.mockImplementation(
68
+ async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
69
+ )
70
+ retryLastMutation.mockClear()
71
+ })
72
+
73
+ it('routes markAsRead through the guarded mutation path', async () => {
74
+ const { result } = renderActions([makeNotification('n1')])
75
+ await act(async () => {
76
+ await result.current.markAsRead('n1')
77
+ })
78
+ expect(runMutationMock).toHaveBeenCalledTimes(1)
79
+ const input = lastRunMutationInput()
80
+ expect(input.context.resourceKind).toBe('notification')
81
+ expect(input.context.retryLastMutation).toBe(retryLastMutation)
82
+ expect(input.mutationPayload).toEqual({ id: 'n1' })
83
+ expect(apiCallOrThrowMock).toHaveBeenCalledWith('/api/notifications/n1/read', { method: 'PUT' })
84
+ })
85
+
86
+ it('routes executeAction through the guarded mutation path and returns the href', async () => {
87
+ apiCallMock.mockResolvedValue({ ok: true, result: { href: '/go' } })
88
+ const { result } = renderActions([makeNotification('n1')])
89
+ let returned: { href?: string } = {}
90
+ await act(async () => {
91
+ returned = await result.current.executeAction('n1', 'approve')
92
+ })
93
+ expect(runMutationMock).toHaveBeenCalledTimes(1)
94
+ const input = lastRunMutationInput()
95
+ expect(input.context.resourceKind).toBe('notification')
96
+ expect(input.mutationPayload).toEqual({ id: 'n1', actionId: 'approve' })
97
+ expect(apiCallMock).toHaveBeenCalledWith(
98
+ '/api/notifications/n1/action',
99
+ expect.objectContaining({ method: 'POST' }),
100
+ )
101
+ expect(returned.href).toBe('/go')
102
+ })
103
+
104
+ it('routes dismiss through the guarded mutation path', async () => {
105
+ const { result } = renderActions([makeNotification('n1')])
106
+ await act(async () => {
107
+ await result.current.dismiss('n1')
108
+ })
109
+ expect(runMutationMock).toHaveBeenCalledTimes(1)
110
+ const input = lastRunMutationInput()
111
+ expect(input.context.resourceKind).toBe('notification')
112
+ expect(input.mutationPayload).toEqual({ id: 'n1' })
113
+ expect(apiCallOrThrowMock).toHaveBeenCalledWith('/api/notifications/n1/dismiss', { method: 'PUT' })
114
+ })
115
+
116
+ it('routes undoDismiss through the guarded mutation path', async () => {
117
+ const { result } = renderActions([makeNotification('n1')])
118
+ await act(async () => {
119
+ await result.current.dismiss('n1')
120
+ })
121
+ runMutationMock.mockClear()
122
+ apiCallMock.mockClear()
123
+ apiCallOrThrowMock.mockClear()
124
+ await act(async () => {
125
+ await result.current.undoDismiss()
126
+ })
127
+ expect(runMutationMock).toHaveBeenCalledTimes(1)
128
+ const input = lastRunMutationInput()
129
+ expect(input.context.resourceKind).toBe('notification')
130
+ expect(input.mutationPayload).toEqual({ id: 'n1', status: 'unread' })
131
+ expect(apiCallOrThrowMock).toHaveBeenCalledWith(
132
+ '/api/notifications/n1/restore',
133
+ expect.objectContaining({ method: 'PUT' }),
134
+ )
135
+ })
136
+
137
+ it('routes markAllRead through the guarded mutation path', async () => {
138
+ const { result } = renderActions([makeNotification('n1')])
139
+ await act(async () => {
140
+ await result.current.markAllRead()
141
+ })
142
+ expect(runMutationMock).toHaveBeenCalledTimes(1)
143
+ const input = lastRunMutationInput()
144
+ expect(input.context.resourceKind).toBe('notification')
145
+ expect(apiCallOrThrowMock).toHaveBeenCalledWith('/api/notifications/mark-all-read', { method: 'PUT' })
146
+ })
147
+ })
@@ -1,8 +1,18 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
3
  import { apiCall, apiCallOrThrow } from '../utils/apiCall'
4
+ import { useGuardedMutation } from '../injection/useGuardedMutation'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
4
6
  import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
5
7
 
8
+ const NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'
9
+
10
+ type NotificationMutationContext = {
11
+ formId: string
12
+ resourceKind: string
13
+ retryLastMutation: () => Promise<boolean>
14
+ }
15
+
6
16
  export type NotificationDismissUndoState = {
7
17
  notification: NotificationDto
8
18
  previousStatus: 'read' | 'unread'
@@ -31,29 +41,44 @@ export function useNotificationActions(
31
41
  const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)
32
42
  const dismissUndoTimerRef = React.useRef<number | null>(null)
33
43
 
44
+ const t = useT()
45
+ const { runMutation, retryLastMutation } = useGuardedMutation<NotificationMutationContext>({
46
+ contextId: NOTIFICATION_ACTIONS_CONTEXT_ID,
47
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
48
+ })
49
+
34
50
  const markAsRead = React.useCallback(async (id: string) => {
35
- await apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' })
51
+ await runMutation({
52
+ operation: () => apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' }),
53
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },
54
+ mutationPayload: { id },
55
+ })
36
56
  setNotifications((prev) =>
37
57
  prev.map((n) =>
38
58
  n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,
39
59
  ),
40
60
  )
41
61
  setUnreadCount((prev) => Math.max(0, prev - 1))
42
- }, [setNotifications, setUnreadCount])
62
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])
43
63
 
44
64
  React.useEffect(() => {
45
65
  markAsReadRef.current = markAsRead
46
66
  }, [markAsRead])
47
67
 
48
68
  const executeAction = React.useCallback(async (id: string, actionId: string) => {
49
- const result = await apiCall<{ ok: boolean; href?: string }>(
50
- `/api/notifications/${id}/action`,
51
- {
52
- method: 'POST',
53
- headers: { 'content-type': 'application/json' },
54
- body: JSON.stringify({ actionId }),
55
- },
56
- )
69
+ const result = await runMutation({
70
+ operation: () =>
71
+ apiCall<{ ok: boolean; href?: string }>(
72
+ `/api/notifications/${id}/action`,
73
+ {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ actionId }),
77
+ },
78
+ ),
79
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },
80
+ mutationPayload: { id, actionId },
81
+ })
57
82
 
58
83
  if (result.ok) {
59
84
  setNotifications((prev) =>
@@ -65,11 +90,15 @@ export function useNotificationActions(
65
90
  }
66
91
 
67
92
  return { href: result.result?.href }
68
- }, [setNotifications, setUnreadCount])
93
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])
69
94
 
70
95
  const dismiss = React.useCallback(
71
96
  async (id: string) => {
72
- await apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' })
97
+ await runMutation({
98
+ operation: () => apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' }),
99
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },
100
+ mutationPayload: { id },
101
+ })
73
102
  const notification = notifications.find((n) => n.id === id)
74
103
  setNotifications((prev) => prev.filter((n) => n.id !== id))
75
104
  if (notification?.status === 'unread') {
@@ -86,7 +115,7 @@ export function useNotificationActions(
86
115
  }, 6000)
87
116
  }
88
117
  },
89
- [notifications, setNotifications, setUnreadCount],
118
+ [notifications, setNotifications, setUnreadCount, runMutation, retryLastMutation],
90
119
  )
91
120
 
92
121
  React.useEffect(() => {
@@ -95,10 +124,15 @@ export function useNotificationActions(
95
124
 
96
125
  const undoDismiss = React.useCallback(async () => {
97
126
  if (!dismissUndo) return
98
- await apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
99
- method: 'PUT',
100
- headers: { 'content-type': 'application/json' },
101
- body: JSON.stringify({ status: dismissUndo.previousStatus }),
127
+ await runMutation({
128
+ operation: () =>
129
+ apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {
130
+ method: 'PUT',
131
+ headers: { 'content-type': 'application/json' },
132
+ body: JSON.stringify({ status: dismissUndo.previousStatus }),
133
+ }),
134
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },
135
+ mutationPayload: { id: dismissUndo.notification.id, status: dismissUndo.previousStatus },
102
136
  })
103
137
 
104
138
  setNotifications((prev) => {
@@ -126,10 +160,14 @@ export function useNotificationActions(
126
160
  window.clearTimeout(dismissUndoTimerRef.current)
127
161
  }
128
162
  setDismissUndo(null)
129
- }, [dismissUndo, setNotifications, setUnreadCount])
163
+ }, [dismissUndo, setNotifications, setUnreadCount, runMutation, retryLastMutation])
130
164
 
131
165
  const markAllRead = React.useCallback(async () => {
132
- await apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' })
166
+ await runMutation({
167
+ operation: () => apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' }),
168
+ context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },
169
+ mutationPayload: {},
170
+ })
133
171
  setNotifications((prev) =>
134
172
  prev.map((n) =>
135
173
  n.status === 'unread'
@@ -138,7 +176,7 @@ export function useNotificationActions(
138
176
  ),
139
177
  )
140
178
  setUnreadCount(0)
141
- }, [setNotifications, setUnreadCount])
179
+ }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])
142
180
 
143
181
  React.useEffect(() => {
144
182
  return () => {
@@ -72,6 +72,13 @@ describe('TagInput primitive', () => {
72
72
  expect(screen.getByText('stay')).toBeInTheDocument()
73
73
  })
74
74
 
75
+ it('localizes the remove button accessible name via removeTagLabel', () => {
76
+ render(<Harness initial={['Call']} removeTagLabel={(tag) => `Usuń ${tag}`} aria-label="tags" />)
77
+ expect(screen.queryByRole('button', { name: 'Remove Call' })).not.toBeInTheDocument()
78
+ fireEvent.click(screen.getByRole('button', { name: 'Usuń Call' }))
79
+ expect(screen.queryByText('Call')).not.toBeInTheDocument()
80
+ })
81
+
75
82
  it('blocks further additions when maxTags is reached', () => {
76
83
  render(<Harness initial={['a', 'b']} maxTags={2} aria-label="tags" />)
77
84
  const input = getInput()
@@ -25,6 +25,15 @@ export type TagInputProps = {
25
25
  * the Figma `Tag Input` Filled state with `Right Icon=true`.
26
26
  */
27
27
  rightIcon?: InputProps['rightIcon']
28
+ /**
29
+ * Builds the accessible label for each tag's remove (×) button. Lets callers
30
+ * localize the action; defaults to the English `Remove {tag}`.
31
+ */
32
+ removeTagLabel?: (tag: string) => string
33
+ }
34
+
35
+ function defaultRemoveTagLabel(tag: string): string {
36
+ return `Remove ${tag}`
28
37
  }
29
38
 
30
39
  function escapeRegExp(input: string): string {
@@ -58,6 +67,7 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
58
67
  'aria-label': ariaLabel,
59
68
  'aria-invalid': ariaInvalid,
60
69
  rightIcon,
70
+ removeTagLabel = defaultRemoveTagLabel,
61
71
  },
62
72
  ref,
63
73
  ) => {
@@ -199,7 +209,7 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
199
209
  shape="square"
200
210
  disabled={disabled}
201
211
  onRemove={() => removeTagAt(index)}
202
- removeAriaLabel={`Remove ${tag}`}
212
+ removeAriaLabel={removeTagLabel(tag)}
203
213
  >
204
214
  {tag}
205
215
  </Tag>