@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { screen, fireEvent } from '@testing-library/react'
|
|
7
|
+
import { RowActions, type RowActionItem } from '../RowActions'
|
|
8
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
9
|
+
|
|
10
|
+
// Regression coverage for the QA findings on PR #6685:
|
|
11
|
+
// #6718 — the menu could not be driven with the arrow keys, so reaching an
|
|
12
|
+
// item meant Tabbing through every other row's trigger first.
|
|
13
|
+
// #6719 — the panel had a fixed `w-44`, so the Polish "Zarejestruj push
|
|
14
|
+
// ponownie" overhung its right border by ~10px.
|
|
15
|
+
// #6719 is the same defect ActionsDropdown carried as #3580, and is fixed the
|
|
16
|
+
// same way here.
|
|
17
|
+
const LONG_POLISH_LABEL = 'Zarejestruj push ponownie'
|
|
18
|
+
const TRIGGER_LABEL = 'Open actions'
|
|
19
|
+
|
|
20
|
+
function renderMenu(items: RowActionItem[]) {
|
|
21
|
+
return renderWithProviders(<RowActions items={items} />, { dict: {} })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function openMenu() {
|
|
25
|
+
// The panel needs two renders: the click opens the menu, and measuring the
|
|
26
|
+
// trigger sets the anchor that actually renders the portal. Focus lands as
|
|
27
|
+
// part of that second commit — no timer involved, so nothing to advance here.
|
|
28
|
+
fireEvent.click(screen.getByRole('button', { name: TRIGGER_LABEL }))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const THREE_ITEMS: RowActionItem[] = [
|
|
32
|
+
{ id: 'set-primary', label: 'Set as primary', onSelect: jest.fn() },
|
|
33
|
+
{ id: 'poll-now', label: 'Poll now', onSelect: jest.fn() },
|
|
34
|
+
{ id: 'disconnect', label: 'Disconnect', destructive: true, onSelect: jest.fn() },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function itemNamed(name: string) {
|
|
38
|
+
return screen.getByRole('menuitem', { name })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('RowActions — panel width (#6719)', () => {
|
|
42
|
+
|
|
43
|
+
it('grows to fit a long label instead of clipping it with a fixed width', () => {
|
|
44
|
+
renderMenu([{ id: 'register-push', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
|
|
45
|
+
openMenu()
|
|
46
|
+
|
|
47
|
+
const menu = screen.getByRole('menu')
|
|
48
|
+
expect(menu.className).toContain('w-max')
|
|
49
|
+
expect(menu.className).toContain('min-w-44')
|
|
50
|
+
expect(menu.className).toContain('max-w-xs')
|
|
51
|
+
// The bug was a hard `w-44` that longer localized labels overflowed.
|
|
52
|
+
expect(menu.className).not.toMatch(/(^|\s)w-44(\s|$)/)
|
|
53
|
+
|
|
54
|
+
expect(screen.getByText(LONG_POLISH_LABEL)).toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('stacks items as flex children so w-max measures the widest one', () => {
|
|
58
|
+
renderMenu(THREE_ITEMS)
|
|
59
|
+
openMenu()
|
|
60
|
+
|
|
61
|
+
// Load-bearing, not cosmetic. Button is `inline-flex`, so under a block
|
|
62
|
+
// panel the items are inline-level and `max-content` is all of them laid
|
|
63
|
+
// end to end — far past `max-w-xs`, which pinned the panel to the cap
|
|
64
|
+
// regardless of its labels. Blockifying them makes `w-max` mean what it says.
|
|
65
|
+
const menu = screen.getByRole('menu')
|
|
66
|
+
expect(menu.className).toContain('flex')
|
|
67
|
+
expect(menu.className).toContain('flex-col')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('lets an over-long label wrap rather than overflow the panel', () => {
|
|
71
|
+
renderMenu([{ id: 'register-push', label: LONG_POLISH_LABEL, onSelect: jest.fn() }])
|
|
72
|
+
openMenu()
|
|
73
|
+
|
|
74
|
+
const item = itemNamed(LONG_POLISH_LABEL)
|
|
75
|
+
// Button is `whitespace-nowrap` by default — that default is what pushed the
|
|
76
|
+
// label past the border, so it has to be overridden here.
|
|
77
|
+
expect(item.className).toContain('whitespace-normal')
|
|
78
|
+
expect(item.className).toContain('h-auto')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('RowActions — keyboard navigation (#6718)', () => {
|
|
83
|
+
|
|
84
|
+
it('focuses the first item when the menu opens', () => {
|
|
85
|
+
renderMenu(THREE_ITEMS)
|
|
86
|
+
openMenu()
|
|
87
|
+
|
|
88
|
+
expect(document.activeElement).toBe(itemNamed('Set as primary'))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('moves down the list with ArrowDown and wraps at the end', () => {
|
|
92
|
+
renderMenu(THREE_ITEMS)
|
|
93
|
+
openMenu()
|
|
94
|
+
|
|
95
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
96
|
+
expect(document.activeElement).toBe(itemNamed('Poll now'))
|
|
97
|
+
|
|
98
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
99
|
+
expect(document.activeElement).toBe(itemNamed('Disconnect'))
|
|
100
|
+
|
|
101
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
102
|
+
expect(document.activeElement).toBe(itemNamed('Set as primary'))
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('moves up the list with ArrowUp and wraps at the start', () => {
|
|
106
|
+
renderMenu(THREE_ITEMS)
|
|
107
|
+
openMenu()
|
|
108
|
+
|
|
109
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' })
|
|
110
|
+
expect(document.activeElement).toBe(itemNamed('Disconnect'))
|
|
111
|
+
|
|
112
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' })
|
|
113
|
+
expect(document.activeElement).toBe(itemNamed('Poll now'))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('jumps to the first and last item with Home and End', () => {
|
|
117
|
+
renderMenu(THREE_ITEMS)
|
|
118
|
+
openMenu()
|
|
119
|
+
|
|
120
|
+
fireEvent.keyDown(document, { key: 'End' })
|
|
121
|
+
expect(document.activeElement).toBe(itemNamed('Disconnect'))
|
|
122
|
+
|
|
123
|
+
fireEvent.keyDown(document, { key: 'Home' })
|
|
124
|
+
expect(document.activeElement).toBe(itemNamed('Set as primary'))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('keeps Tab inside the menu instead of escaping to the next row', () => {
|
|
128
|
+
renderMenu(THREE_ITEMS)
|
|
129
|
+
openMenu()
|
|
130
|
+
|
|
131
|
+
fireEvent.keyDown(document, { key: 'Tab' })
|
|
132
|
+
expect(document.activeElement).toBe(itemNamed('Poll now'))
|
|
133
|
+
|
|
134
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
|
|
135
|
+
expect(document.activeElement).toBe(itemNamed('Set as primary'))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does not steal focus when the menu opens on hover (#6771)', () => {
|
|
139
|
+
// The menu also opens on hover. Moving focus into it on *every* open meant
|
|
140
|
+
// that brushing the mouse over ⋯ while typing in a list's search field
|
|
141
|
+
// pulled the caret out of the field and dropped the next keystrokes.
|
|
142
|
+
renderMenu(THREE_ITEMS)
|
|
143
|
+
const field = document.createElement('input')
|
|
144
|
+
document.body.appendChild(field)
|
|
145
|
+
field.focus()
|
|
146
|
+
|
|
147
|
+
fireEvent.pointerEnter(screen.getByRole('button', { name: TRIGGER_LABEL }), { pointerType: 'mouse' })
|
|
148
|
+
|
|
149
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
150
|
+
expect(document.activeElement).toBe(field)
|
|
151
|
+
field.remove()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('leaves typing alone while a hover-opened menu is showing (#6771)', () => {
|
|
155
|
+
// The key handler is on `document`, so without scoping it would also
|
|
156
|
+
// swallow the arrows and Home/End of someone typing in that field.
|
|
157
|
+
renderMenu(THREE_ITEMS)
|
|
158
|
+
const field = document.createElement('input')
|
|
159
|
+
document.body.appendChild(field)
|
|
160
|
+
field.focus()
|
|
161
|
+
fireEvent.pointerEnter(screen.getByRole('button', { name: TRIGGER_LABEL }), { pointerType: 'mouse' })
|
|
162
|
+
|
|
163
|
+
for (const key of ['ArrowDown', 'ArrowUp', 'Home', 'End']) {
|
|
164
|
+
fireEvent.keyDown(document, { key })
|
|
165
|
+
expect(document.activeElement).toBe(field)
|
|
166
|
+
}
|
|
167
|
+
field.remove()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not reset the highlighted item when the page scrolls', () => {
|
|
171
|
+
// Repositioning on scroll updates the anchor rect; that must not be read as
|
|
172
|
+
// a fresh open and send focus back to the first item.
|
|
173
|
+
renderMenu(THREE_ITEMS)
|
|
174
|
+
openMenu()
|
|
175
|
+
fireEvent.keyDown(document, { key: 'End' })
|
|
176
|
+
expect(document.activeElement).toBe(itemNamed('Disconnect'))
|
|
177
|
+
|
|
178
|
+
fireEvent.scroll(window)
|
|
179
|
+
|
|
180
|
+
expect(document.activeElement).toBe(itemNamed('Disconnect'))
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('closes on Escape and returns focus to the trigger', () => {
|
|
184
|
+
renderMenu(THREE_ITEMS)
|
|
185
|
+
openMenu()
|
|
186
|
+
|
|
187
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
188
|
+
|
|
189
|
+
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
|
190
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: TRIGGER_LABEL }))
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('still runs the action when an item is activated', () => {
|
|
194
|
+
const onSelect = jest.fn()
|
|
195
|
+
renderMenu([{ id: 'poll-now', label: 'Poll now', onSelect }])
|
|
196
|
+
openMenu()
|
|
197
|
+
|
|
198
|
+
fireEvent.click(itemNamed('Poll now'))
|
|
199
|
+
|
|
200
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -138,6 +138,7 @@ export function KpiCard({
|
|
|
138
138
|
<div className={wrapperClass}>
|
|
139
139
|
{headerRow}
|
|
140
140
|
<p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">--</p>
|
|
141
|
+
{footer != null && <div className="mt-3">{footer}</div>}
|
|
141
142
|
</div>
|
|
142
143
|
)
|
|
143
144
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import { KpiCard } from '../KpiCard'
|
|
8
|
+
|
|
9
|
+
describe('KpiCard', () => {
|
|
10
|
+
it('keeps the footer when there is no value, because the footer is what explains the dash', () => {
|
|
11
|
+
render(<KpiCard title="Latest NPS" value={null} footer={<span>Never answered a survey</span>} />)
|
|
12
|
+
expect(screen.getByText('--')).toBeTruthy()
|
|
13
|
+
expect(screen.getByText('Never answered a survey')).toBeTruthy()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('renders the footer beside a value as before', () => {
|
|
17
|
+
render(<KpiCard title="Orders" value={3} footer={<span>last 30 days</span>} />)
|
|
18
|
+
expect(screen.getByText('last 30 days')).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -61,6 +61,7 @@ function AttachmentsSectionImpl({
|
|
|
61
61
|
const [deleteOpen, setDeleteOpen] = React.useState(false)
|
|
62
62
|
const [deleteTarget, setDeleteTarget] = React.useState<AttachmentItem | null>(null)
|
|
63
63
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
|
64
|
+
const uploadingRef = React.useRef(false)
|
|
64
65
|
|
|
65
66
|
const load = React.useCallback(async (targetPage = 1, replace = true) => {
|
|
66
67
|
if (!recordId) return
|
|
@@ -121,9 +122,12 @@ function AttachmentsSectionImpl({
|
|
|
121
122
|
|
|
122
123
|
const acceptFiles = React.useCallback(
|
|
123
124
|
async (files: FileList | null) => {
|
|
124
|
-
if (!files || !files.length || !recordId) return
|
|
125
|
+
if (!files || !files.length || !recordId || uploadingRef.current) return
|
|
126
|
+
uploadingRef.current = true
|
|
125
127
|
setError(null)
|
|
126
128
|
setIsUploading(true)
|
|
129
|
+
let uploadedCount = 0
|
|
130
|
+
let uploadError: string | null = null
|
|
127
131
|
try {
|
|
128
132
|
for (const file of Array.from(files)) {
|
|
129
133
|
const fd = new FormData()
|
|
@@ -139,12 +143,19 @@ function AttachmentsSectionImpl({
|
|
|
139
143
|
const message = call.result?.error || t('attachments.library.upload.failed', 'Upload failed.')
|
|
140
144
|
throw new Error(message)
|
|
141
145
|
}
|
|
146
|
+
uploadedCount += 1
|
|
142
147
|
}
|
|
143
|
-
await load(1, true)
|
|
144
|
-
onChanged?.()
|
|
145
148
|
} catch (err: any) {
|
|
146
|
-
|
|
149
|
+
uploadError = err?.message || t('attachments.library.upload.failed', 'Upload failed.')
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
if (uploadedCount > 0) {
|
|
153
|
+
await load(1, true)
|
|
154
|
+
onChanged?.()
|
|
155
|
+
}
|
|
147
156
|
} finally {
|
|
157
|
+
if (uploadError) setError(uploadError)
|
|
158
|
+
uploadingRef.current = false
|
|
148
159
|
setIsUploading(false)
|
|
149
160
|
if (fileInputRef.current) {
|
|
150
161
|
fileInputRef.current.value = ''
|
|
@@ -271,12 +282,13 @@ function AttachmentsSectionImpl({
|
|
|
271
282
|
type="file"
|
|
272
283
|
multiple
|
|
273
284
|
className="hidden"
|
|
285
|
+
disabled={isUploading}
|
|
274
286
|
onChange={(event) => void acceptFiles(event.target.files)}
|
|
275
287
|
/>
|
|
276
288
|
</div>
|
|
277
289
|
)}
|
|
278
290
|
|
|
279
|
-
{error ? <p className="text-xs font-medium text-status-error-text">{error}</p> : null}
|
|
291
|
+
{error ? <p role="alert" className="text-xs font-medium text-status-error-text">{error}</p> : null}
|
|
280
292
|
|
|
281
293
|
{loading ? (
|
|
282
294
|
<div className="text-sm text-muted-foreground">{t('attachments.library.loading', 'Loading attachments…')}</div>
|
|
@@ -31,6 +31,9 @@ type Translator = (key: string, fallback?: string, params?: Record<string, strin
|
|
|
31
31
|
|
|
32
32
|
const NOTES_PAGE_SIZE = 20
|
|
33
33
|
|
|
34
|
+
const noteBodyClassName =
|
|
35
|
+
'break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs'
|
|
36
|
+
|
|
34
37
|
const isTestEnv =
|
|
35
38
|
typeof process !== 'undefined' &&
|
|
36
39
|
(process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
|
|
@@ -58,6 +61,10 @@ export type CommentSummary = {
|
|
|
58
61
|
authorEmail?: string | null
|
|
59
62
|
dealId?: string | null
|
|
60
63
|
dealTitle?: string | null
|
|
64
|
+
/** Generic per-note context (e.g. the child record a note came from), rendered like the linked deal. */
|
|
65
|
+
contextLabel?: string | null
|
|
66
|
+
/** Optional link for `contextLabel`; without it the label renders as plain text. */
|
|
67
|
+
contextHref?: string | null
|
|
61
68
|
appearanceIcon?: string | null
|
|
62
69
|
appearanceColor?: string | null
|
|
63
70
|
}
|
|
@@ -221,6 +228,18 @@ export type NotesSectionProps<C = unknown> = {
|
|
|
221
228
|
hideMarkdownToggle?: boolean
|
|
222
229
|
/** Hide the appearance (icon/color) affordances: the palette buttons and the appearance dialog. */
|
|
223
230
|
disableAppearance?: boolean
|
|
231
|
+
/**
|
|
232
|
+
* Per-note edit permission. Returning `false` hides the note's edit and appearance buttons
|
|
233
|
+
* and disables click-to-edit on its body. Presentational only — the server stays the authority.
|
|
234
|
+
*/
|
|
235
|
+
canEditNote?: (note: CommentSummary) => boolean
|
|
236
|
+
/** Per-note delete permission. Returning `false` hides the note's delete button. */
|
|
237
|
+
canDeleteNote?: (note: CommentSummary) => boolean
|
|
238
|
+
/**
|
|
239
|
+
* Hide the composer, the add actions (including the `onActionChange` action) and every
|
|
240
|
+
* per-note mutation control. Notes and "load more" still render.
|
|
241
|
+
*/
|
|
242
|
+
readOnly?: boolean
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
export function sanitizeHexColor(value: string | null): string | null {
|
|
@@ -329,6 +348,9 @@ function NotesSectionImpl<C = unknown>({
|
|
|
329
348
|
forceMarkdown,
|
|
330
349
|
hideMarkdownToggle,
|
|
331
350
|
disableAppearance,
|
|
351
|
+
canEditNote,
|
|
352
|
+
canDeleteNote,
|
|
353
|
+
readOnly = false,
|
|
332
354
|
}: NotesSectionProps<C>) {
|
|
333
355
|
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
334
356
|
const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
|
|
@@ -470,7 +492,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
470
492
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
471
493
|
const formRef = React.useRef<HTMLFormElement | null>(null)
|
|
472
494
|
const focusComposer = React.useCallback(() => {
|
|
473
|
-
if (!hasEntity) return
|
|
495
|
+
if (!hasEntity || readOnly) return
|
|
474
496
|
setComposerOpen(true)
|
|
475
497
|
window.requestAnimationFrame(() => {
|
|
476
498
|
if (isMarkdownActive) {
|
|
@@ -486,7 +508,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
486
508
|
element.focus()
|
|
487
509
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
488
510
|
})
|
|
489
|
-
}, [formRef, hasEntity, isMarkdownActive])
|
|
511
|
+
}, [formRef, hasEntity, isMarkdownActive, readOnly])
|
|
490
512
|
const [appearanceDialogState, setAppearanceDialogState] = React.useState<
|
|
491
513
|
| { mode: 'create'; icon: string | null; color: string | null }
|
|
492
514
|
| { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
|
|
@@ -582,7 +604,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
582
604
|
|
|
583
605
|
React.useEffect(() => {
|
|
584
606
|
if (!onActionChange) return
|
|
585
|
-
if (!notes.length) {
|
|
607
|
+
if (!notes.length || readOnly) {
|
|
586
608
|
onActionChange(null)
|
|
587
609
|
return
|
|
588
610
|
}
|
|
@@ -593,7 +615,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
593
615
|
icon: <Plus className="mr-2 h-4 w-4" />,
|
|
594
616
|
})
|
|
595
617
|
return () => onActionChange(null)
|
|
596
|
-
}, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length])
|
|
618
|
+
}, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length, readOnly])
|
|
597
619
|
|
|
598
620
|
const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {
|
|
599
621
|
if (!element) return
|
|
@@ -629,12 +651,12 @@ function NotesSectionImpl<C = unknown>({
|
|
|
629
651
|
}, [notes.length, pagedMode])
|
|
630
652
|
|
|
631
653
|
React.useEffect(() => {
|
|
632
|
-
if (hasEntity) return
|
|
654
|
+
if (hasEntity && !readOnly) return
|
|
633
655
|
setComposerOpen(false)
|
|
634
656
|
setDraftBody('')
|
|
635
657
|
setDraftIcon(null)
|
|
636
658
|
setDraftColor(null)
|
|
637
|
-
}, [hasEntity])
|
|
659
|
+
}, [hasEntity, readOnly])
|
|
638
660
|
|
|
639
661
|
const visibleNotes = React.useMemo(
|
|
640
662
|
() => (pagedMode ? notes : notes.slice(0, visibleCount)),
|
|
@@ -1167,7 +1189,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1167
1189
|
{loadError ? <ErrorMessage label={loadError} className="mt-3" /> : null}
|
|
1168
1190
|
|
|
1169
1191
|
<div className="space-y-3">
|
|
1170
|
-
{!composerOpen && hasVisibleNotes && !onActionChange ? (
|
|
1192
|
+
{!composerOpen && hasVisibleNotes && !onActionChange && !readOnly ? (
|
|
1171
1193
|
<div className="flex justify-end">
|
|
1172
1194
|
<Button
|
|
1173
1195
|
type="button"
|
|
@@ -1190,11 +1212,14 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1190
1212
|
visibleNotes.map((note) => {
|
|
1191
1213
|
const author = noteAuthorLabel(note)
|
|
1192
1214
|
const isAppearanceSaving = appearanceDialogSaving && editingAppearanceNoteId === note.id
|
|
1193
|
-
const
|
|
1215
|
+
const canEdit = !readOnly && (canEditNote ? canEditNote(note) : true)
|
|
1216
|
+
const canDelete = !readOnly && (canDeleteNote ? canDeleteNote(note) : true)
|
|
1217
|
+
const isEditingContent = canEdit && contentEditor.id === note.id
|
|
1194
1218
|
const displayIcon = note.appearanceIcon ?? null
|
|
1195
1219
|
const displayColor = note.appearanceColor ?? null
|
|
1196
1220
|
const timestampValue = note.createdAt
|
|
1197
1221
|
const fallbackTimestampLabel = formatDateTime(note.createdAt) ?? emptyLabel
|
|
1222
|
+
const contextLabel = note.contextLabel && note.contextLabel.length ? note.contextLabel : null
|
|
1198
1223
|
return (
|
|
1199
1224
|
<div key={note.id} className="group space-y-2 rounded-lg border bg-card p-4">
|
|
1200
1225
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
@@ -1221,59 +1246,77 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1221
1246
|
</a>
|
|
1222
1247
|
</div>
|
|
1223
1248
|
) : null}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
>
|
|
1236
|
-
<Pencil className="h-4 w-4" />
|
|
1237
|
-
</Button>
|
|
1238
|
-
{showAppearanceControls ? (
|
|
1239
|
-
<Button
|
|
1240
|
-
type="button"
|
|
1241
|
-
variant="ghost"
|
|
1242
|
-
size="icon"
|
|
1243
|
-
onClick={(event) => {
|
|
1244
|
-
event.stopPropagation()
|
|
1245
|
-
setAppearanceDialogError(null)
|
|
1246
|
-
setAppearanceDialogState({
|
|
1247
|
-
mode: 'edit',
|
|
1248
|
-
noteId: note.id,
|
|
1249
|
-
icon: note.appearanceIcon ?? null,
|
|
1250
|
-
color: note.appearanceColor ?? null,
|
|
1251
|
-
})
|
|
1252
|
-
}}
|
|
1253
|
-
disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
|
|
1254
|
-
>
|
|
1255
|
-
{isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
|
|
1256
|
-
</Button>
|
|
1249
|
+
{contextLabel ? (
|
|
1250
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
1251
|
+
<ArrowUpRightSquare className="h-3.5 w-3.5" />
|
|
1252
|
+
{note.contextHref ? (
|
|
1253
|
+
<a href={note.contextHref} className="font-medium text-foreground hover:underline">
|
|
1254
|
+
{contextLabel}
|
|
1255
|
+
</a>
|
|
1256
|
+
) : (
|
|
1257
|
+
<span className="font-medium text-foreground">{contextLabel}</span>
|
|
1258
|
+
)}
|
|
1259
|
+
</div>
|
|
1257
1260
|
) : null}
|
|
1258
|
-
<Button
|
|
1259
|
-
type="button"
|
|
1260
|
-
variant="ghost"
|
|
1261
|
-
size="icon"
|
|
1262
|
-
onClick={(event) => {
|
|
1263
|
-
event.stopPropagation()
|
|
1264
|
-
void handleDeleteNote(note)
|
|
1265
|
-
}}
|
|
1266
|
-
disabled={deletingNoteId === note.id}
|
|
1267
|
-
>
|
|
1268
|
-
{deletingNoteId === note.id ? (
|
|
1269
|
-
<span className="relative flex h-4 w-4 items-center justify-center text-destructive">
|
|
1270
|
-
<span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
|
|
1271
|
-
</span>
|
|
1272
|
-
) : (
|
|
1273
|
-
<Trash2 className="h-4 w-4" />
|
|
1274
|
-
)}
|
|
1275
|
-
</Button>
|
|
1276
1261
|
</div>
|
|
1262
|
+
{canEdit || canDelete ? (
|
|
1263
|
+
<div
|
|
1264
|
+
className={`flex items-center gap-2 transition-opacity ${
|
|
1265
|
+
isEditingContent ? 'opacity-100' : 'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100'
|
|
1266
|
+
}`}
|
|
1267
|
+
>
|
|
1268
|
+
{canEdit ? (
|
|
1269
|
+
<Button
|
|
1270
|
+
type="button"
|
|
1271
|
+
variant="ghost"
|
|
1272
|
+
size="icon"
|
|
1273
|
+
onClick={() => setContentEditor({ id: note.id, value: note.body })}
|
|
1274
|
+
>
|
|
1275
|
+
<Pencil className="h-4 w-4" />
|
|
1276
|
+
</Button>
|
|
1277
|
+
) : null}
|
|
1278
|
+
{showAppearanceControls && canEdit ? (
|
|
1279
|
+
<Button
|
|
1280
|
+
type="button"
|
|
1281
|
+
variant="ghost"
|
|
1282
|
+
size="icon"
|
|
1283
|
+
onClick={(event) => {
|
|
1284
|
+
event.stopPropagation()
|
|
1285
|
+
setAppearanceDialogError(null)
|
|
1286
|
+
setAppearanceDialogState({
|
|
1287
|
+
mode: 'edit',
|
|
1288
|
+
noteId: note.id,
|
|
1289
|
+
icon: note.appearanceIcon ?? null,
|
|
1290
|
+
color: note.appearanceColor ?? null,
|
|
1291
|
+
})
|
|
1292
|
+
}}
|
|
1293
|
+
disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
|
|
1294
|
+
>
|
|
1295
|
+
{isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
|
|
1296
|
+
</Button>
|
|
1297
|
+
) : null}
|
|
1298
|
+
{canDelete ? (
|
|
1299
|
+
<Button
|
|
1300
|
+
type="button"
|
|
1301
|
+
variant="ghost"
|
|
1302
|
+
size="icon"
|
|
1303
|
+
onClick={(event) => {
|
|
1304
|
+
event.stopPropagation()
|
|
1305
|
+
void handleDeleteNote(note)
|
|
1306
|
+
}}
|
|
1307
|
+
disabled={deletingNoteId === note.id}
|
|
1308
|
+
>
|
|
1309
|
+
{deletingNoteId === note.id ? (
|
|
1310
|
+
<span className="relative flex h-4 w-4 items-center justify-center text-destructive">
|
|
1311
|
+
<span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
|
|
1312
|
+
</span>
|
|
1313
|
+
) : (
|
|
1314
|
+
<Trash2 className="h-4 w-4" />
|
|
1315
|
+
)}
|
|
1316
|
+
</Button>
|
|
1317
|
+
) : null}
|
|
1318
|
+
</div>
|
|
1319
|
+
) : null}
|
|
1277
1320
|
</div>
|
|
1278
1321
|
{isEditingContent ? (
|
|
1279
1322
|
<div className="space-y-2" onKeyDown={handleContentEditorKeyDown}>
|
|
@@ -1325,7 +1368,7 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1325
1368
|
</Button>
|
|
1326
1369
|
</div>
|
|
1327
1370
|
</div>
|
|
1328
|
-
) : (
|
|
1371
|
+
) : canEdit ? (
|
|
1329
1372
|
<div
|
|
1330
1373
|
role="button"
|
|
1331
1374
|
tabIndex={0}
|
|
@@ -1335,7 +1378,16 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1335
1378
|
>
|
|
1336
1379
|
<MarkdownPreview
|
|
1337
1380
|
remarkPlugins={markdownPlugins}
|
|
1338
|
-
className=
|
|
1381
|
+
className={noteBodyClassName}
|
|
1382
|
+
>
|
|
1383
|
+
{note.body}
|
|
1384
|
+
</MarkdownPreview>
|
|
1385
|
+
</div>
|
|
1386
|
+
) : (
|
|
1387
|
+
<div className="text-sm">
|
|
1388
|
+
<MarkdownPreview
|
|
1389
|
+
remarkPlugins={markdownPlugins}
|
|
1390
|
+
className={noteBodyClassName}
|
|
1339
1391
|
>
|
|
1340
1392
|
{note.body}
|
|
1341
1393
|
</MarkdownPreview>
|
|
@@ -1348,11 +1400,15 @@ function NotesSectionImpl<C = unknown>({
|
|
|
1348
1400
|
<TabEmptyState
|
|
1349
1401
|
title={emptyState.title}
|
|
1350
1402
|
description={emptyState.description}
|
|
1351
|
-
action={
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1403
|
+
action={
|
|
1404
|
+
readOnly
|
|
1405
|
+
? undefined
|
|
1406
|
+
: {
|
|
1407
|
+
label: emptyState.actionLabel,
|
|
1408
|
+
onClick: focusComposer,
|
|
1409
|
+
disabled: isSubmitting || !hasEntity,
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1356
1412
|
/>
|
|
1357
1413
|
)}
|
|
1358
1414
|
{isLoading || (pagedMode ? !hasMore : visibleCount >= notes.length) ? null : (
|
|
@@ -130,6 +130,25 @@ function useRuntimeStatus(token: string | null): RuntimeStatus | null {
|
|
|
130
130
|
return status
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// The banner floats above every dialog and sheet but lives outside their DOM,
|
|
134
|
+
// so an open layer treats a press or focus on it as an outside interaction and
|
|
135
|
+
// closes itself instead of letting the banner handle it. The layers listen on
|
|
136
|
+
// `document`, so these events are stopped in React's capture phase at the
|
|
137
|
+
// banner root: that follows the React tree, which also covers the migrate
|
|
138
|
+
// confirmation portaled to <body>. Buttons act on `click`, which is untouched;
|
|
139
|
+
// a bubble-phase pointer-down, mouse-down, touch-start or focus handler added
|
|
140
|
+
// inside the banner would never fire.
|
|
141
|
+
function stopOutsideDismiss(event: React.SyntheticEvent): void {
|
|
142
|
+
event.nativeEvent.stopPropagation()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const OUTSIDE_DISMISS_SHIELD = {
|
|
146
|
+
onPointerDownCapture: stopOutsideDismiss,
|
|
147
|
+
onMouseDownCapture: stopOutsideDismiss,
|
|
148
|
+
onTouchStartCapture: stopOutsideDismiss,
|
|
149
|
+
onFocusCapture: stopOutsideDismiss,
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
function reloadPage(): void {
|
|
134
153
|
if (typeof window === 'undefined') return
|
|
135
154
|
try {
|
|
@@ -251,6 +270,7 @@ export function DevRuntimeDiagnosticsBanner() {
|
|
|
251
270
|
|
|
252
271
|
return (
|
|
253
272
|
<div
|
|
273
|
+
{...OUTSIDE_DISMISS_SHIELD}
|
|
254
274
|
data-testid="dev-runtime-diagnostics-banner"
|
|
255
275
|
data-health={status.health}
|
|
256
276
|
role={status.health === 'unavailable' ? 'alert' : 'status'}
|
|
@@ -260,7 +280,10 @@ export function DevRuntimeDiagnosticsBanner() {
|
|
|
260
280
|
// very high z-index, so the banner stacks ABOVE the bubble rather than
|
|
261
281
|
// trying to outrank it. `max-w-4xl` keeps the action row on one line on
|
|
262
282
|
// desktop; it still wraps (never scrolls) once the viewport is narrow.
|
|
263
|
-
|
|
283
|
+
// `pointer-events-auto` keeps it clickable while a modal dialog sets
|
|
284
|
+
// `pointer-events: none` on <body>; without it a click falls through to
|
|
285
|
+
// the dialog overlay underneath.
|
|
286
|
+
className={`pointer-events-auto fixed inset-x-3 bottom-20 z-banner flex flex-col gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-4xl ${TONE_CLASSES[tone]}`}
|
|
264
287
|
>
|
|
265
288
|
<div className="flex items-start justify-between gap-2">
|
|
266
289
|
<div className="min-w-0 flex-1">
|