@open-mercato/ui 0.6.6-develop.6317.1.b3be10ab84 → 0.6.6-develop.6331.1.a33b8e99b7
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +13 -2
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/OrganizationScopeBoundary.js +33 -0
- package/dist/backend/OrganizationScopeBoundary.js.map +7 -0
- package/dist/backend/conflicts/index.js +50 -13
- package/dist/backend/conflicts/index.js.map +2 -2
- package/dist/backend/detail/AddressesSection.js +9 -5
- package/dist/backend/detail/AddressesSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +11 -6
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/injection/recordContext.js +40 -0
- package/dist/backend/injection/recordContext.js.map +7 -0
- package/dist/backend/notifications/NotificationPanel.js +14 -28
- package/dist/backend/notifications/NotificationPanel.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +19 -1
- package/dist/backend/utils/optimisticLock.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +26 -2
- package/src/backend/CrudForm.tsx +4 -1
- package/src/backend/OrganizationScopeBoundary.tsx +54 -0
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +59 -0
- package/src/backend/__tests__/NotesSection.test.tsx +71 -0
- package/src/backend/__tests__/OrganizationScopeBoundary.test.tsx +74 -0
- package/src/backend/conflicts/__tests__/recordLockDeferral.test.ts +99 -0
- package/src/backend/conflicts/index.ts +96 -19
- package/src/backend/detail/AddressesSection.tsx +12 -7
- package/src/backend/detail/NotesSection.tsx +19 -8
- package/src/backend/injection/recordContext.tsx +101 -0
- package/src/backend/notifications/NotificationPanel.tsx +35 -38
- package/src/backend/notifications/__tests__/NotificationPanel.test.tsx +124 -0
- package/src/backend/utils/optimisticLock.ts +36 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
extractOptimisticLockConflict,
|
|
3
|
+
extractRecordLockConflict,
|
|
4
|
+
type RecordLockConflictBody,
|
|
5
|
+
} from '../utils/optimisticLock'
|
|
2
6
|
import { showRecordConflict } from './store'
|
|
3
7
|
|
|
4
8
|
export {
|
|
@@ -21,29 +25,102 @@ export type SurfaceRecordConflictOptions = {
|
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
+
* Handler the enterprise `record_locks` merge-dialog widget registers (on mount)
|
|
29
|
+
* so `surfaceRecordConflict` can defer the OSS conflict bar in favor of the
|
|
30
|
+
* field-level merge dialog. Core/UI stays enterprise-free: enterprise calls
|
|
31
|
+
* {@link registerRecordLockConflictHandler}; this module never imports it.
|
|
28
32
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* MUST return `true` only when it actually owns/renders the surface for THIS
|
|
34
|
+
* conflict (its widget is mounted for the conflicting record). Returning `false`
|
|
35
|
+
* (different record / cannot render) makes `surfaceRecordConflict` fall through
|
|
36
|
+
* to the OSS conflict bar, so a conflict is never swallowed and we never render
|
|
37
|
+
* BOTH the merge dialog and the bar (single surface, S3).
|
|
38
|
+
*/
|
|
39
|
+
export type RecordLockConflictHandler = (conflict: RecordLockConflictBody, error: unknown) => boolean
|
|
40
|
+
|
|
41
|
+
let recordLockConflictHandler: RecordLockConflictHandler | null = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register the record-lock conflict handler (enterprise merge-dialog widget).
|
|
45
|
+
* Returns an unregister function. When a handler is registered,
|
|
46
|
+
* `surfaceRecordConflict` defers `record_lock_conflict` 409s to it instead of
|
|
47
|
+
* rendering the OSS conflict bar (single surface, S3).
|
|
48
|
+
*
|
|
49
|
+
* SAFETY: when NO handler is registered (widget absent/removed),
|
|
50
|
+
* `surfaceRecordConflict` still renders the OSS conflict bar for ANY 409 — a
|
|
51
|
+
* conflict is never silently swallowed.
|
|
52
|
+
*/
|
|
53
|
+
export function registerRecordLockConflictHandler(handler: RecordLockConflictHandler): () => void {
|
|
54
|
+
recordLockConflictHandler = handler
|
|
55
|
+
return () => {
|
|
56
|
+
if (recordLockConflictHandler === handler) recordLockConflictHandler = null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Test-only: inspect whether a record-lock conflict handler is registered. */
|
|
61
|
+
export function hasRecordLockConflictHandlerForTest(): boolean {
|
|
62
|
+
return recordLockConflictHandler !== null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Test-only: clear any registered record-lock conflict handler. */
|
|
66
|
+
export function resetRecordLockConflictHandlerForTest(): void {
|
|
67
|
+
recordLockConflictHandler = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Single conflict surface (S3). Resolves a 409 to exactly one UI:
|
|
72
|
+
*
|
|
73
|
+
* - `record_lock_conflict` payload AND a merge-dialog handler registered →
|
|
74
|
+
* defer to the handler (no conflict bar) and return `true`.
|
|
75
|
+
* - otherwise (OSS `optimistic_lock_conflict`, OR a `record_lock_conflict`
|
|
76
|
+
* with NO handler registered) → render the OSS conflict bar and return
|
|
77
|
+
* `true`. A conflict is ALWAYS surfaced; the worst case is plainer UX, never
|
|
78
|
+
* a silently-swallowed conflict.
|
|
79
|
+
* - not a recognized conflict → return `false` so callers fall back to their
|
|
80
|
+
* normal error handling.
|
|
31
81
|
*/
|
|
32
82
|
export function surfaceRecordConflict(
|
|
33
83
|
error: unknown,
|
|
34
84
|
t: Translate,
|
|
35
85
|
options: SurfaceRecordConflictOptions = {},
|
|
36
86
|
): boolean {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
const recordLockConflict = extractRecordLockConflict(error)
|
|
88
|
+
if (recordLockConflict && recordLockConflictHandler) {
|
|
89
|
+
// The widget owns the surface ONLY when it handles this record's conflict
|
|
90
|
+
// (returns true). If it declines (different record / not mounted for it),
|
|
91
|
+
// fall through to the OSS bar so the conflict is never swallowed and we
|
|
92
|
+
// never render both the merge dialog AND the bar.
|
|
93
|
+
if (recordLockConflictHandler(recordLockConflict, error)) return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ossConflict = extractOptimisticLockConflict(error)
|
|
97
|
+
if (ossConflict) {
|
|
98
|
+
showRecordConflict({
|
|
99
|
+
message: t(
|
|
100
|
+
'ui.forms.flash.recordModified',
|
|
101
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
102
|
+
),
|
|
103
|
+
title: options.title ?? null,
|
|
104
|
+
currentUpdatedAt: ossConflict.currentUpdatedAt,
|
|
105
|
+
onRefresh: options.onRefresh ?? null,
|
|
106
|
+
})
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No handler is registered but the server returned a record-lock 409: still
|
|
111
|
+
// render the OSS bar so the conflict is never swallowed.
|
|
112
|
+
if (recordLockConflict) {
|
|
113
|
+
showRecordConflict({
|
|
114
|
+
message: t(
|
|
115
|
+
'ui.forms.flash.recordModified',
|
|
116
|
+
'This record was modified by someone else. Refresh and try again.',
|
|
117
|
+
),
|
|
118
|
+
title: options.title ?? null,
|
|
119
|
+
currentUpdatedAt: null,
|
|
120
|
+
onRefresh: options.onRefresh ?? null,
|
|
121
|
+
})
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return false
|
|
49
126
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
5
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
5
6
|
import { LoadingMessage } from '@open-mercato/ui/backend/detail'
|
|
6
7
|
import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
7
8
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -40,13 +41,14 @@ export type AddressSummary = {
|
|
|
40
41
|
postalCode?: string | null
|
|
41
42
|
country?: string | null
|
|
42
43
|
isPrimary?: boolean
|
|
44
|
+
updatedAt?: string | null
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export type AddressDataAdapter<C = unknown> = {
|
|
46
48
|
list: (params: { entityId: string | null; context?: C }) => Promise<AddressSummary[]>
|
|
47
49
|
create: (params: { entityId: string; payload: AddressInput; context?: C }) => Promise<{ id?: string } | void>
|
|
48
|
-
update: (params: { id: string; payload: AddressInput; context?: C }) => Promise<void>
|
|
49
|
-
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
50
|
+
update: (params: { id: string; payload: AddressInput; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
51
|
+
delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export type AddressesSectionProps<C = unknown> = {
|
|
@@ -205,7 +207,8 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
205
207
|
pushLoading()
|
|
206
208
|
setIsSubmitting(true)
|
|
207
209
|
try {
|
|
208
|
-
|
|
210
|
+
const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
|
|
211
|
+
await dataAdapter.update({ id, payload, updatedAt: addressUpdatedAt, context: dataContext })
|
|
209
212
|
setAddresses((prev) => {
|
|
210
213
|
return prev.map((address) => {
|
|
211
214
|
if (address.id !== id) {
|
|
@@ -230,6 +233,7 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
230
233
|
})
|
|
231
234
|
flash(label('success', 'Address saved.'), 'success')
|
|
232
235
|
} catch (err) {
|
|
236
|
+
surfaceRecordConflict(err, t)
|
|
233
237
|
const message =
|
|
234
238
|
err instanceof Error && err.message ? err.message : label('error', 'Failed to save address.')
|
|
235
239
|
const error = new Error(message) as Error & { details?: unknown }
|
|
@@ -242,7 +246,7 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
242
246
|
popLoading()
|
|
243
247
|
}
|
|
244
248
|
},
|
|
245
|
-
[dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
|
|
249
|
+
[addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
|
|
246
250
|
)
|
|
247
251
|
|
|
248
252
|
const handleDelete = React.useCallback(
|
|
@@ -253,20 +257,21 @@ function AddressesSectionImpl<C = unknown>({
|
|
|
253
257
|
pushLoading()
|
|
254
258
|
setIsSubmitting(true)
|
|
255
259
|
try {
|
|
256
|
-
|
|
260
|
+
const addressUpdatedAt = addresses.find((address) => address.id === id)?.updatedAt ?? null
|
|
261
|
+
await dataAdapter.delete({ id, updatedAt: addressUpdatedAt, context: dataContext })
|
|
257
262
|
setAddresses((prev) => prev.filter((address) => address.id !== id))
|
|
258
263
|
flash(label('deleted', 'Address deleted.'), 'success')
|
|
259
264
|
} catch (err) {
|
|
260
265
|
const message =
|
|
261
266
|
err instanceof Error && err.message ? err.message : label('error', 'Failed to delete address.')
|
|
262
|
-
flash(message, 'error')
|
|
267
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
263
268
|
throw err
|
|
264
269
|
} finally {
|
|
265
270
|
setIsSubmitting(false)
|
|
266
271
|
popLoading()
|
|
267
272
|
}
|
|
268
273
|
},
|
|
269
|
-
[dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading],
|
|
274
|
+
[addresses, dataAdapter, dataContext, label, normalizedEntityId, popLoading, pushLoading, t],
|
|
270
275
|
)
|
|
271
276
|
|
|
272
277
|
const displayAddresses = React.useMemo<AddressValue[]>(() => {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
SelectValue,
|
|
17
17
|
} from '@open-mercato/ui/primitives/select'
|
|
18
18
|
import { flash } from '../FlashMessages'
|
|
19
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
19
20
|
import { SwitchableMarkdownInput } from '../inputs/SwitchableMarkdownInput'
|
|
20
21
|
import { ErrorMessage } from './ErrorMessage'
|
|
21
22
|
import { LoadingMessage } from './LoadingMessage'
|
|
@@ -48,6 +49,7 @@ export type CommentSummary = {
|
|
|
48
49
|
id: string
|
|
49
50
|
body: string
|
|
50
51
|
createdAt: string
|
|
52
|
+
updatedAt?: string | null
|
|
51
53
|
authorUserId?: string | null
|
|
52
54
|
authorName?: string | null
|
|
53
55
|
authorEmail?: string | null
|
|
@@ -87,8 +89,8 @@ export type NotesDataAdapter<C = unknown> = {
|
|
|
87
89
|
totalPages: number
|
|
88
90
|
}>
|
|
89
91
|
create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
|
|
90
|
-
update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
|
|
91
|
-
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
92
|
+
update: (params: { id: string; patch: NotesUpdatePayload; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
93
|
+
delete: (params: { id: string; updatedAt?: string | null; context?: C }) => Promise<void>
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
type RenderIconFn = (icon: string, className?: string) => React.ReactNode
|
|
@@ -221,6 +223,12 @@ export function mapCommentSummary(input: unknown): CommentSummary {
|
|
|
221
223
|
: typeof data.created_at === 'string'
|
|
222
224
|
? data.created_at
|
|
223
225
|
: new Date().toISOString()
|
|
226
|
+
const updatedAt =
|
|
227
|
+
typeof data.updatedAt === 'string'
|
|
228
|
+
? data.updatedAt
|
|
229
|
+
: typeof data.updated_at === 'string'
|
|
230
|
+
? data.updated_at
|
|
231
|
+
: null
|
|
224
232
|
const authorUserId =
|
|
225
233
|
typeof data.authorUserId === 'string'
|
|
226
234
|
? data.authorUserId
|
|
@@ -267,6 +275,7 @@ export function mapCommentSummary(input: unknown): CommentSummary {
|
|
|
267
275
|
id,
|
|
268
276
|
body,
|
|
269
277
|
createdAt,
|
|
278
|
+
updatedAt,
|
|
270
279
|
authorUserId,
|
|
271
280
|
authorName,
|
|
272
281
|
authorEmail,
|
|
@@ -678,7 +687,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
678
687
|
return true
|
|
679
688
|
} catch (err) {
|
|
680
689
|
const message = err instanceof Error ? err.message : label('error')
|
|
681
|
-
flash(message, 'error')
|
|
690
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
682
691
|
return false
|
|
683
692
|
} finally {
|
|
684
693
|
setIsSubmitting(false)
|
|
@@ -699,6 +708,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
699
708
|
: undefined
|
|
700
709
|
const sanitizedColor =
|
|
701
710
|
patch.appearanceColor !== undefined ? sanitizeHexColor(patch.appearanceColor ?? null) : undefined
|
|
711
|
+
const noteUpdatedAt = notes.find((comment) => comment.id === noteId)?.updatedAt ?? null
|
|
702
712
|
try {
|
|
703
713
|
await dataAdapter.update({
|
|
704
714
|
id: noteId,
|
|
@@ -707,6 +717,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
707
717
|
appearanceIcon: sanitizedIcon,
|
|
708
718
|
appearanceColor: sanitizedColor,
|
|
709
719
|
},
|
|
720
|
+
updatedAt: noteUpdatedAt,
|
|
710
721
|
context: dataContext,
|
|
711
722
|
})
|
|
712
723
|
setNotes((prev) => {
|
|
@@ -723,11 +734,11 @@ function NotesSectionImpl<C = unknown>({
|
|
|
723
734
|
flash(label('updateSuccess'), 'success')
|
|
724
735
|
} catch (error) {
|
|
725
736
|
const message = error instanceof Error ? error.message : label('updateError')
|
|
726
|
-
flash(message, 'error')
|
|
737
|
+
if (!surfaceRecordConflict(error, t)) flash(message, 'error')
|
|
727
738
|
throw error instanceof Error ? error : new Error(message)
|
|
728
739
|
}
|
|
729
740
|
},
|
|
730
|
-
[dataAdapter, dataContext, t],
|
|
741
|
+
[dataAdapter, dataContext, notes, t],
|
|
731
742
|
)
|
|
732
743
|
|
|
733
744
|
const handleDeleteNote = React.useCallback(
|
|
@@ -740,7 +751,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
740
751
|
setDeletingNoteId(note.id)
|
|
741
752
|
pushLoading()
|
|
742
753
|
try {
|
|
743
|
-
await dataAdapter.delete({ id: note.id, context: dataContext })
|
|
754
|
+
await dataAdapter.delete({ id: note.id, updatedAt: note.updatedAt ?? null, context: dataContext })
|
|
744
755
|
setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
|
|
745
756
|
if (pagedMode) {
|
|
746
757
|
setVisibleCount((prev) => Math.max(0, prev - 1))
|
|
@@ -748,13 +759,13 @@ function NotesSectionImpl<C = unknown>({
|
|
|
748
759
|
flash(label('deleteSuccess', 'Note deleted'), 'success')
|
|
749
760
|
} catch (err) {
|
|
750
761
|
const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
|
|
751
|
-
flash(message, 'error')
|
|
762
|
+
if (!surfaceRecordConflict(err, t)) flash(message, 'error')
|
|
752
763
|
} finally {
|
|
753
764
|
setDeletingNoteId(null)
|
|
754
765
|
popLoading()
|
|
755
766
|
}
|
|
756
767
|
},
|
|
757
|
-
[confirm, dataAdapter, dataContext, label, popLoading, pushLoading],
|
|
768
|
+
[confirm, dataAdapter, dataContext, label, popLoading, pushLoading, t],
|
|
758
769
|
)
|
|
759
770
|
|
|
760
771
|
const handleSubmit = React.useCallback(
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AppShell-owned transport for the current detail record's injection context
|
|
6
|
+
* (Phase 0 / S2). The `backend:record:current` injection spot is mounted once,
|
|
7
|
+
* globally, by `AppShell` — before page `children` — so a page cannot pass props
|
|
8
|
+
* "up" to it. Instead, a detail page publishes its record context through this
|
|
9
|
+
* provider; `AppShell` merges it into the spot's base `{ path, query }` context.
|
|
10
|
+
*
|
|
11
|
+
* Keeps `packages/ui`/core enterprise-free: this is a plain context object, no
|
|
12
|
+
* record_locks dependency, no second `<InjectionSpot>`.
|
|
13
|
+
*/
|
|
14
|
+
export type RecordInjectionContext = {
|
|
15
|
+
/** record_locks resource key, e.g. `customers.person`, `sales.order`. */
|
|
16
|
+
resourceKind: string
|
|
17
|
+
/** The record's id. */
|
|
18
|
+
resourceId: string
|
|
19
|
+
/** Server `updated_at` (ISO) for the optimistic-lock floor + action-log base. */
|
|
20
|
+
updatedAt?: string | null
|
|
21
|
+
/** Optional record payload for field-level merge UX. */
|
|
22
|
+
data?: Record<string, unknown> | null
|
|
23
|
+
/** The detail screen path this context belongs to (used for stale-context guarding). */
|
|
24
|
+
path?: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a normalized record injection context. Returns `null` when the required
|
|
29
|
+
* keys are missing so callers can publish unconditionally.
|
|
30
|
+
*/
|
|
31
|
+
export function buildRecordInjectionContext(input: {
|
|
32
|
+
resourceKind: string | null | undefined
|
|
33
|
+
resourceId: string | null | undefined
|
|
34
|
+
updatedAt?: string | Date | null
|
|
35
|
+
data?: Record<string, unknown> | null
|
|
36
|
+
path?: string | null
|
|
37
|
+
}): RecordInjectionContext | null {
|
|
38
|
+
const resourceKind = typeof input.resourceKind === 'string' ? input.resourceKind.trim() : ''
|
|
39
|
+
const resourceId = typeof input.resourceId === 'string' ? input.resourceId.trim() : ''
|
|
40
|
+
if (!resourceKind || !resourceId) return null
|
|
41
|
+
const updatedAt = input.updatedAt instanceof Date
|
|
42
|
+
? input.updatedAt.toISOString()
|
|
43
|
+
: (typeof input.updatedAt === 'string' && input.updatedAt.trim().length ? input.updatedAt.trim() : null)
|
|
44
|
+
return {
|
|
45
|
+
resourceKind,
|
|
46
|
+
resourceId,
|
|
47
|
+
updatedAt,
|
|
48
|
+
data: input.data ?? null,
|
|
49
|
+
path: typeof input.path === 'string' && input.path.length ? input.path : null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type RecordInjectionContextSetter = (context: RecordInjectionContext | null) => void
|
|
54
|
+
|
|
55
|
+
const BackendRecordInjectionContextSetterContext = React.createContext<RecordInjectionContextSetter | null>(null)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* AppShell renders this provider around page `children`, supplying the setter
|
|
59
|
+
* that publishes record context into AppShell-owned state. Detail pages call
|
|
60
|
+
* `useSetCurrentRecordInjectionContext()` to publish/clear.
|
|
61
|
+
*/
|
|
62
|
+
export function BackendRecordInjectionContextProvider({
|
|
63
|
+
setCurrentRecordInjectionContext,
|
|
64
|
+
children,
|
|
65
|
+
}: {
|
|
66
|
+
setCurrentRecordInjectionContext: RecordInjectionContextSetter
|
|
67
|
+
children: React.ReactNode
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<BackendRecordInjectionContextSetterContext.Provider value={setCurrentRecordInjectionContext}>
|
|
71
|
+
{children}
|
|
72
|
+
</BackendRecordInjectionContextSetterContext.Provider>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Publish the current detail record's injection context to the AppShell-owned
|
|
78
|
+
* `backend:record:current` mount, and clear it automatically on unmount /
|
|
79
|
+
* dependency change. Pass `null` to clear (e.g. while the record is loading).
|
|
80
|
+
*
|
|
81
|
+
* ```tsx
|
|
82
|
+
* useSetCurrentRecordInjectionContext(
|
|
83
|
+
* buildRecordInjectionContext({ resourceKind: 'customers.person', resourceId: id, updatedAt, data, path }),
|
|
84
|
+
* )
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function useSetCurrentRecordInjectionContext(context: RecordInjectionContext | null): void {
|
|
88
|
+
const setter = React.useContext(BackendRecordInjectionContextSetterContext)
|
|
89
|
+
// Serialize so we only re-publish when the meaningful fields change.
|
|
90
|
+
const serialized = context
|
|
91
|
+
? JSON.stringify({ k: context.resourceKind, i: context.resourceId, u: context.updatedAt ?? null, p: context.path ?? null })
|
|
92
|
+
: null
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (!setter) return
|
|
95
|
+
setter(context)
|
|
96
|
+
return () => {
|
|
97
|
+
setter(null)
|
|
98
|
+
}
|
|
99
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
100
|
+
}, [setter, serialized])
|
|
101
|
+
}
|
|
@@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'luci
|
|
|
5
5
|
import { Button } from '../../primitives/button'
|
|
6
6
|
import { IconButton } from '../../primitives/icon-button'
|
|
7
7
|
import { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'
|
|
8
|
+
import { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'
|
|
8
9
|
import { NotificationItem } from './NotificationItem'
|
|
9
10
|
import type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
|
|
10
11
|
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -97,6 +98,12 @@ export function NotificationPanel({
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
const handleFilterChange = React.useCallback((next: string) => {
|
|
102
|
+
if (next === 'all' || next === 'unread' || next === 'action') {
|
|
103
|
+
setFilter(next)
|
|
104
|
+
}
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
100
107
|
// Preserve the body scroll lock contract that consumers (and integration
|
|
101
108
|
// tests) rely on. Radix Dialog locks scroll via react-remove-scroll which
|
|
102
109
|
// does not set `document.body.style.overflow` — we set it ourselves so the
|
|
@@ -124,15 +131,17 @@ export function NotificationPanel({
|
|
|
124
131
|
</SheetTitle>
|
|
125
132
|
<div className="flex items-center gap-1">
|
|
126
133
|
{unreadCount > 0 ? (
|
|
127
|
-
<
|
|
134
|
+
<Button
|
|
128
135
|
type="button"
|
|
136
|
+
variant="link"
|
|
137
|
+
size="sm"
|
|
129
138
|
onClick={handleMarkAllRead}
|
|
130
139
|
disabled={markingAllRead}
|
|
131
|
-
className="
|
|
140
|
+
className="gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline"
|
|
132
141
|
>
|
|
133
142
|
{markingAllRead ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
134
143
|
{t('notifications.markAllRead', 'Mark all read')}
|
|
135
|
-
</
|
|
144
|
+
</Button>
|
|
136
145
|
) : null}
|
|
137
146
|
<IconButton
|
|
138
147
|
type="button"
|
|
@@ -146,41 +155,29 @@ export function NotificationPanel({
|
|
|
146
155
|
</div>
|
|
147
156
|
</div>
|
|
148
157
|
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
</span>
|
|
173
|
-
) : null}
|
|
174
|
-
{isActive ? (
|
|
175
|
-
<span
|
|
176
|
-
className="absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground"
|
|
177
|
-
aria-hidden="true"
|
|
178
|
-
/>
|
|
179
|
-
) : null}
|
|
180
|
-
</button>
|
|
181
|
-
)
|
|
182
|
-
})}
|
|
183
|
-
</div>
|
|
158
|
+
<Tabs value={filter} onValueChange={handleFilterChange} variant="underline">
|
|
159
|
+
<TabsList className="flex w-full px-5">
|
|
160
|
+
{(['all', 'unread', 'action'] as const).map((value) => {
|
|
161
|
+
const label =
|
|
162
|
+
value === 'all'
|
|
163
|
+
? t('notifications.filters.all', 'All')
|
|
164
|
+
: value === 'unread'
|
|
165
|
+
? t('notifications.filters.unread', 'Unread')
|
|
166
|
+
: t('notifications.filters.actionRequired', 'Action Required')
|
|
167
|
+
const count =
|
|
168
|
+
value === 'unread' && unreadCount > 0
|
|
169
|
+
? unreadCount > 99
|
|
170
|
+
? '99+'
|
|
171
|
+
: unreadCount
|
|
172
|
+
: undefined
|
|
173
|
+
return (
|
|
174
|
+
<TabsTrigger key={value} value={value} count={count}>
|
|
175
|
+
{label}
|
|
176
|
+
</TabsTrigger>
|
|
177
|
+
)
|
|
178
|
+
})}
|
|
179
|
+
</TabsList>
|
|
180
|
+
</Tabs>
|
|
184
181
|
|
|
185
182
|
{dismissUndo && onUndoDismiss && (
|
|
186
183
|
<div className="border-b bg-muted/50 px-4 py-2 text-sm">
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen, fireEvent, within, act } from '@testing-library/react'
|
|
7
|
+
import { NotificationPanel } from '../NotificationPanel'
|
|
8
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
9
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
10
|
+
|
|
11
|
+
// NotificationItem (rendered per row) calls next/navigation's useRouter, which
|
|
12
|
+
// throws outside an App Router provider. Stub it so the panel can render rows.
|
|
13
|
+
jest.mock('next/navigation', () => ({
|
|
14
|
+
useRouter: () => ({
|
|
15
|
+
push: jest.fn(),
|
|
16
|
+
replace: jest.fn(),
|
|
17
|
+
prefetch: jest.fn(),
|
|
18
|
+
refresh: jest.fn(),
|
|
19
|
+
back: jest.fn(),
|
|
20
|
+
forward: jest.fn(),
|
|
21
|
+
}),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const t = ((key: string, fallback?: unknown) =>
|
|
25
|
+
typeof fallback === 'string' ? fallback : key) as TranslateFn
|
|
26
|
+
|
|
27
|
+
function buildNotification(overrides: Partial<NotificationDto>): NotificationDto {
|
|
28
|
+
return {
|
|
29
|
+
id: 'n',
|
|
30
|
+
type: 'system.generic',
|
|
31
|
+
title: 'Title',
|
|
32
|
+
severity: 'info',
|
|
33
|
+
status: 'unread',
|
|
34
|
+
actions: [],
|
|
35
|
+
createdAt: '2026-06-18T00:00:00.000Z',
|
|
36
|
+
...overrides,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildProps(overrides: Partial<React.ComponentProps<typeof NotificationPanel>> = {}) {
|
|
41
|
+
return {
|
|
42
|
+
open: true,
|
|
43
|
+
onOpenChange: jest.fn(),
|
|
44
|
+
notifications: [] as NotificationDto[],
|
|
45
|
+
unreadCount: 0,
|
|
46
|
+
onMarkAsRead: jest.fn().mockResolvedValue(undefined),
|
|
47
|
+
onExecuteAction: jest.fn().mockResolvedValue({}),
|
|
48
|
+
onDismiss: jest.fn().mockResolvedValue(undefined),
|
|
49
|
+
onMarkAllRead: jest.fn().mockResolvedValue(undefined),
|
|
50
|
+
t,
|
|
51
|
+
...overrides,
|
|
52
|
+
} satisfies React.ComponentProps<typeof NotificationPanel>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('NotificationPanel controls use shared primitives', () => {
|
|
56
|
+
it('renders the filter tabs through the Tabs primitive, not raw buttons', () => {
|
|
57
|
+
render(<NotificationPanel {...buildProps()} />)
|
|
58
|
+
|
|
59
|
+
const tablist = screen.getByRole('tablist')
|
|
60
|
+
const tabs = within(tablist).getAllByRole('tab')
|
|
61
|
+
|
|
62
|
+
expect(tabs).toHaveLength(3)
|
|
63
|
+
// The Tabs primitive stamps data-slot on every trigger; a raw <button>
|
|
64
|
+
// would not, so this guards against regressing back to hand-rolled markup.
|
|
65
|
+
tabs.forEach((tab) => expect(tab).toHaveAttribute('data-slot', 'tabs-trigger'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('marks the active filter with aria-selected and switches on click', () => {
|
|
69
|
+
render(<NotificationPanel {...buildProps()} />)
|
|
70
|
+
|
|
71
|
+
const allTab = screen.getByRole('tab', { name: /All/i })
|
|
72
|
+
const unreadTab = screen.getByRole('tab', { name: /Unread/i })
|
|
73
|
+
|
|
74
|
+
expect(allTab).toHaveAttribute('aria-selected', 'true')
|
|
75
|
+
expect(unreadTab).toHaveAttribute('aria-selected', 'false')
|
|
76
|
+
|
|
77
|
+
fireEvent.click(unreadTab)
|
|
78
|
+
|
|
79
|
+
expect(unreadTab).toHaveAttribute('aria-selected', 'true')
|
|
80
|
+
expect(allTab).toHaveAttribute('aria-selected', 'false')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('shows the unread count badge on the Unread tab and caps it at 99+', () => {
|
|
84
|
+
const { rerender } = render(<NotificationPanel {...buildProps({ unreadCount: 5 })} />)
|
|
85
|
+
expect(within(screen.getByRole('tab', { name: /Unread/i })).getByText('5')).toBeInTheDocument()
|
|
86
|
+
|
|
87
|
+
rerender(<NotificationPanel {...buildProps({ unreadCount: 150 })} />)
|
|
88
|
+
expect(within(screen.getByRole('tab', { name: /Unread/i })).getByText('99+')).toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders Mark all read as a Button primitive and invokes the handler', async () => {
|
|
92
|
+
const onMarkAllRead = jest.fn().mockResolvedValue(undefined)
|
|
93
|
+
render(<NotificationPanel {...buildProps({ unreadCount: 3, onMarkAllRead })} />)
|
|
94
|
+
|
|
95
|
+
const markAllButton = screen.getByRole('button', { name: /Mark all read/i })
|
|
96
|
+
expect(markAllButton).toHaveAttribute('data-slot', 'button')
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
fireEvent.click(markAllButton)
|
|
100
|
+
})
|
|
101
|
+
expect(onMarkAllRead).toHaveBeenCalledTimes(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('hides Mark all read when there are no unread notifications', () => {
|
|
105
|
+
render(<NotificationPanel {...buildProps({ unreadCount: 0 })} />)
|
|
106
|
+
expect(screen.queryByRole('button', { name: /Mark all read/i })).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('keeps filtering behavior when switching tabs', () => {
|
|
110
|
+
const notifications = [
|
|
111
|
+
buildNotification({ id: 'a', title: 'Unread one', status: 'unread' }),
|
|
112
|
+
buildNotification({ id: 'b', title: 'Read one', status: 'read' }),
|
|
113
|
+
]
|
|
114
|
+
render(<NotificationPanel {...buildProps({ notifications, unreadCount: 1 })} />)
|
|
115
|
+
|
|
116
|
+
expect(screen.getByText('Unread one')).toBeInTheDocument()
|
|
117
|
+
expect(screen.getByText('Read one')).toBeInTheDocument()
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByRole('tab', { name: /Unread/i }))
|
|
120
|
+
|
|
121
|
+
expect(screen.getByText('Unread one')).toBeInTheDocument()
|
|
122
|
+
expect(screen.queryByText('Read one')).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|