@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82
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 +2 -2
- package/dist/backend/AppShell.js +66 -7
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +14 -8
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +108 -11
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/forms/ActionsDropdown.js +35 -5
- package/dist/backend/forms/ActionsDropdown.js.map +2 -2
- package/dist/backend/forms/index.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +6 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/spotIds.js +2 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/DurationInput.js +182 -0
- package/dist/backend/inputs/DurationInput.js.map +7 -0
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/index.js +1 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/nav/navBadges.js +38 -0
- package/dist/backend/nav/navBadges.js.map +7 -0
- package/dist/primitives/segmented-control.js +4 -4
- package/dist/primitives/segmented-control.js.map +1 -1
- package/dist/primitives/sheet.js +21 -11
- package/dist/primitives/sheet.js.map +2 -2
- package/dist/primitives/textarea.js +54 -4
- package/dist/primitives/textarea.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +106 -2
- package/src/backend/CrudForm.tsx +28 -9
- package/src/backend/DataTable.tsx +176 -22
- package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
- package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
- package/src/backend/__tests__/navBadges.test.ts +57 -0
- package/src/backend/forms/ActionsDropdown.tsx +73 -6
- package/src/backend/forms/index.ts +1 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
- package/src/backend/injection/spotIds.ts +8 -0
- package/src/backend/inputs/DurationInput.tsx +207 -0
- package/src/backend/inputs/EventSelect.tsx +2 -0
- package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
- package/src/backend/inputs/index.ts +1 -0
- package/src/backend/nav/navBadges.ts +61 -0
- package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
- package/src/primitives/__tests__/textarea.test.tsx +50 -0
- package/src/primitives/segmented-control.tsx +7 -7
- package/src/primitives/sheet.tsx +7 -1
- package/src/primitives/textarea.tsx +77 -4
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Host-owned bulk actions (the `bulkActions` prop).
|
|
5
|
+
*
|
|
6
|
+
* `progress/AGENTS.md` requires a server-queued bulk action to return
|
|
7
|
+
* `{ ok, progressJobId }` so the top bar can track it, and `ui/AGENTS.md`
|
|
8
|
+
* forbids a page from building its own progress bar instead. The prop path used
|
|
9
|
+
* to DISCARD that result entirely: no toast, no tracking, and a rejected
|
|
10
|
+
* promise became an unhandled rejection. This file pins the fix — and pins the
|
|
11
|
+
* `void` / `true` / `false` returns to their previous behaviour, because the
|
|
12
|
+
* existing callers flash from their own bulk helper and a second toast would be
|
|
13
|
+
* a regression.
|
|
14
|
+
*/
|
|
15
|
+
import * as React from 'react'
|
|
16
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
17
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
18
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
19
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
20
|
+
import { DataTable } from '../DataTable'
|
|
21
|
+
|
|
22
|
+
const mockRouterRefresh = jest.fn()
|
|
23
|
+
|
|
24
|
+
jest.mock('next/navigation', () => ({
|
|
25
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: mockRouterRefresh }),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
const flashMock = jest.fn()
|
|
29
|
+
jest.mock('../FlashMessages', () => ({
|
|
30
|
+
flash: (...args: unknown[]) => flashMock(...args),
|
|
31
|
+
}))
|
|
32
|
+
jest.mock('../confirm-dialog', () => ({
|
|
33
|
+
useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
type Row = { id: string; name: string }
|
|
37
|
+
|
|
38
|
+
class ResizeObserverMock {
|
|
39
|
+
observe() {}
|
|
40
|
+
unobserve() {}
|
|
41
|
+
disconnect() {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderTable(props: Record<string, unknown>) {
|
|
45
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
46
|
+
const view = render(
|
|
47
|
+
React.createElement(
|
|
48
|
+
QueryClientProvider as any,
|
|
49
|
+
{ client: queryClient },
|
|
50
|
+
React.createElement(
|
|
51
|
+
I18nProvider as any,
|
|
52
|
+
{ locale: 'en', dict: {} },
|
|
53
|
+
React.createElement(DataTable as any, props)
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return { ...view, cleanupQueryClient: () => queryClient.clear() }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
61
|
+
|
|
62
|
+
async function selectAllAndRun(label: string) {
|
|
63
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
|
|
64
|
+
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
|
|
65
|
+
fireEvent.click(screen.getByRole('button', { name: label }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('DataTable prop bulk actions', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
;(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserverMock }).ResizeObserver =
|
|
71
|
+
ResizeObserverMock
|
|
72
|
+
flashMock.mockReset()
|
|
73
|
+
mockRouterRefresh.mockReset()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('tracks a queued action that returns a progressJobId and says so', async () => {
|
|
77
|
+
const rendered = renderTable({
|
|
78
|
+
columns,
|
|
79
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
80
|
+
bulkActions: [
|
|
81
|
+
{
|
|
82
|
+
id: 'replay',
|
|
83
|
+
label: 'Replay',
|
|
84
|
+
onExecute: async () => ({ ok: true, progressJobId: 'job-1' }),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await selectAllAndRun('Replay')
|
|
91
|
+
await waitFor(() =>
|
|
92
|
+
expect(flashMock).toHaveBeenCalledWith(
|
|
93
|
+
'Bulk action started. Track progress in the top bar.',
|
|
94
|
+
'success'
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
// A queued action must NOT also trigger an immediate refresh — the work
|
|
98
|
+
// has not happened yet.
|
|
99
|
+
expect(mockRouterRefresh).not.toHaveBeenCalled()
|
|
100
|
+
await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
|
|
101
|
+
} finally {
|
|
102
|
+
rendered.cleanupQueryClient()
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('surfaces a failure result as an error flash instead of swallowing it', async () => {
|
|
107
|
+
const rendered = renderTable({
|
|
108
|
+
columns,
|
|
109
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
110
|
+
bulkActions: [
|
|
111
|
+
{ id: 'replay', label: 'Replay', onExecute: async () => ({ ok: false, message: 'Nope' }) },
|
|
112
|
+
],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await selectAllAndRun('Replay')
|
|
117
|
+
await waitFor(() => expect(flashMock).toHaveBeenCalledWith('Nope', 'error'))
|
|
118
|
+
// A failed action keeps the selection so the operator can retry.
|
|
119
|
+
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
|
120
|
+
} finally {
|
|
121
|
+
rendered.cleanupQueryClient()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('flashes a thrown error rather than leaving an unhandled rejection', async () => {
|
|
126
|
+
const rendered = renderTable({
|
|
127
|
+
columns,
|
|
128
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
129
|
+
bulkActions: [
|
|
130
|
+
{
|
|
131
|
+
id: 'replay',
|
|
132
|
+
label: 'Replay',
|
|
133
|
+
onExecute: async () => {
|
|
134
|
+
throw new Error('boom')
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await selectAllAndRun('Replay')
|
|
142
|
+
await waitFor(() => expect(flashMock).toHaveBeenCalledWith('boom', 'error'))
|
|
143
|
+
} finally {
|
|
144
|
+
rendered.cleanupQueryClient()
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('reports and refreshes for a synchronous result that completed inline', async () => {
|
|
149
|
+
const rendered = renderTable({
|
|
150
|
+
columns,
|
|
151
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
152
|
+
bulkActions: [
|
|
153
|
+
{ id: 'replay', label: 'Replay', onExecute: async () => ({ ok: true, affectedCount: 1 }) },
|
|
154
|
+
],
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await selectAllAndRun('Replay')
|
|
159
|
+
await waitFor(() => expect(flashMock).toHaveBeenCalledWith('Bulk action completed.', 'success'))
|
|
160
|
+
await waitFor(() => expect(mockRouterRefresh).toHaveBeenCalled())
|
|
161
|
+
} finally {
|
|
162
|
+
rendered.cleanupQueryClient()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves a void-returning action exactly as it was — no second toast', async () => {
|
|
167
|
+
const onExecute = jest.fn(async () => {})
|
|
168
|
+
const rendered = renderTable({
|
|
169
|
+
columns,
|
|
170
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
171
|
+
bulkActions: [{ id: 'delete', label: 'Delete selected', onExecute }],
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await selectAllAndRun('Delete selected')
|
|
176
|
+
await waitFor(() => expect(onExecute).toHaveBeenCalled())
|
|
177
|
+
await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
|
|
178
|
+
expect(flashMock).not.toHaveBeenCalled()
|
|
179
|
+
expect(mockRouterRefresh).not.toHaveBeenCalled()
|
|
180
|
+
} finally {
|
|
181
|
+
rendered.cleanupQueryClient()
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps the selection when the action returns false (a declined confirm)', async () => {
|
|
186
|
+
const rendered = renderTable({
|
|
187
|
+
columns,
|
|
188
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
189
|
+
bulkActions: [{ id: 'delete', label: 'Delete selected', onExecute: async () => false }],
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await selectAllAndRun('Delete selected')
|
|
194
|
+
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
|
|
195
|
+
expect(flashMock).not.toHaveBeenCalled()
|
|
196
|
+
} finally {
|
|
197
|
+
rendered.cleanupQueryClient()
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -118,6 +118,82 @@ describe('DataTable SSR render', () => {
|
|
|
118
118
|
}
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
it('renders no expand toggle when expansion props are absent (back-compat)', () => {
|
|
122
|
+
const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
123
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
124
|
+
try {
|
|
125
|
+
render(
|
|
126
|
+
<QueryClientProvider client={queryClient}>
|
|
127
|
+
<I18nProvider locale="en" dict={{}}>
|
|
128
|
+
<DataTable columns={columns} data={[{ id: '1', name: 'Ada' }]} />
|
|
129
|
+
</I18nProvider>
|
|
130
|
+
</QueryClientProvider>,
|
|
131
|
+
)
|
|
132
|
+
expect(screen.queryByRole('button', { name: /expand row/i })).toBeNull()
|
|
133
|
+
expect(screen.queryByRole('button', { name: /collapse row/i })).toBeNull()
|
|
134
|
+
} finally {
|
|
135
|
+
queryClient.clear()
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('shows an expand toggle and reveals sub-rows when expansion is enabled', () => {
|
|
140
|
+
type TreeRow = { id: string; name: string; children?: TreeRow[] }
|
|
141
|
+
const columns: ColumnDef<TreeRow>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
142
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
143
|
+
try {
|
|
144
|
+
render(
|
|
145
|
+
<QueryClientProvider client={queryClient}>
|
|
146
|
+
<I18nProvider locale="en" dict={{}}>
|
|
147
|
+
<DataTable<TreeRow>
|
|
148
|
+
columns={columns}
|
|
149
|
+
data={[{ id: 'parent', name: 'Parent', children: [{ id: 'child', name: 'Child' }] }]}
|
|
150
|
+
getSubRows={(row) => row.children}
|
|
151
|
+
expandable={(row) => Boolean(row.children?.length)}
|
|
152
|
+
/>
|
|
153
|
+
</I18nProvider>
|
|
154
|
+
</QueryClientProvider>,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Child hidden until expanded.
|
|
158
|
+
expect(screen.queryByText('Child')).toBeNull()
|
|
159
|
+
const toggle = screen.getByRole('button', { name: /expand row/i })
|
|
160
|
+
fireEvent.click(toggle)
|
|
161
|
+
expect(screen.getByText('Child')).toBeInTheDocument()
|
|
162
|
+
// Toggle flips to collapse affordance.
|
|
163
|
+
expect(screen.getByRole('button', { name: /collapse row/i })).toBeInTheDocument()
|
|
164
|
+
} finally {
|
|
165
|
+
queryClient.clear()
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('uses controlled expanded state and reports changes for lazy loading', () => {
|
|
170
|
+
type TreeRow = { id: string; name: string; children?: TreeRow[] }
|
|
171
|
+
const columns: ColumnDef<TreeRow>[] = [{ accessorKey: 'name', header: 'Name' }]
|
|
172
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
173
|
+
const onExpandedChange = jest.fn()
|
|
174
|
+
try {
|
|
175
|
+
render(
|
|
176
|
+
<QueryClientProvider client={queryClient}>
|
|
177
|
+
<I18nProvider locale="en" dict={{}}>
|
|
178
|
+
<DataTable<TreeRow>
|
|
179
|
+
columns={columns}
|
|
180
|
+
data={[{ id: 'parent', name: 'Parent' }]}
|
|
181
|
+
getSubRows={(row) => row.children}
|
|
182
|
+
expandable={() => true}
|
|
183
|
+
expanded={{}}
|
|
184
|
+
onExpandedChange={onExpandedChange}
|
|
185
|
+
/>
|
|
186
|
+
</I18nProvider>
|
|
187
|
+
</QueryClientProvider>,
|
|
188
|
+
)
|
|
189
|
+
// A row with no loaded children still shows a toggle (lazy-load affordance).
|
|
190
|
+
fireEvent.click(screen.getByRole('button', { name: /expand row/i }))
|
|
191
|
+
expect(onExpandedChange).toHaveBeenCalled()
|
|
192
|
+
} finally {
|
|
193
|
+
queryClient.clear()
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
121
197
|
it('keeps provided row order in manual sorting mode and reports sorting changes', () => {
|
|
122
198
|
const columns: ColumnDef<Row>[] = [
|
|
123
199
|
{ accessorKey: 'name', header: 'Name' },
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { setNavBadge, clearNavBadge, useNavBadge } from '../nav/navBadges'
|
|
3
|
+
|
|
4
|
+
const HREF = '/backend/caseload'
|
|
5
|
+
const OTHER = '/backend/traces'
|
|
6
|
+
|
|
7
|
+
// The store is a module singleton; every case starts from an empty slot.
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
clearNavBadge(HREF)
|
|
10
|
+
clearNavBadge(OTHER)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('nav badges', () => {
|
|
14
|
+
it('publishes a count to subscribers of that href only', () => {
|
|
15
|
+
const target = renderHook(() => useNavBadge(HREF))
|
|
16
|
+
const bystander = renderHook(() => useNavBadge(OTHER))
|
|
17
|
+
|
|
18
|
+
act(() => setNavBadge(HREF, { count: 3, tone: 'attention' }))
|
|
19
|
+
|
|
20
|
+
expect(target.result.current).toEqual({ count: 3, tone: 'attention' })
|
|
21
|
+
expect(bystander.result.current).toBeNull()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders nothing for a zero count rather than an empty chip', () => {
|
|
25
|
+
const { result } = renderHook(() => useNavBadge(HREF))
|
|
26
|
+
|
|
27
|
+
act(() => setNavBadge(HREF, { count: 4 }))
|
|
28
|
+
expect(result.current?.count).toBe(4)
|
|
29
|
+
|
|
30
|
+
act(() => setNavBadge(HREF, { count: 0 }))
|
|
31
|
+
expect(result.current).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('clears the slot when the count becomes unknown', () => {
|
|
35
|
+
const { result } = renderHook(() => useNavBadge(HREF))
|
|
36
|
+
|
|
37
|
+
act(() => setNavBadge(HREF, { count: 2 }))
|
|
38
|
+
act(() => clearNavBadge(HREF))
|
|
39
|
+
|
|
40
|
+
expect(result.current).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('ignores a non-finite count instead of rendering NaN', () => {
|
|
44
|
+
const { result } = renderHook(() => useNavBadge(HREF))
|
|
45
|
+
|
|
46
|
+
act(() => setNavBadge(HREF, { count: 5 }))
|
|
47
|
+
act(() => setNavBadge(HREF, { count: Number.NaN }))
|
|
48
|
+
|
|
49
|
+
expect(result.current).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('ignores an empty href', () => {
|
|
53
|
+
const { result } = renderHook(() => useNavBadge(''))
|
|
54
|
+
act(() => setNavBadge('', { count: 7 }))
|
|
55
|
+
expect(result.current).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -19,11 +19,30 @@ export type ActionItem = {
|
|
|
19
19
|
disabled?: boolean
|
|
20
20
|
/** Show a loading spinner instead of the icon */
|
|
21
21
|
loading?: boolean
|
|
22
|
+
/** Render the item in the destructive token (e.g. Delete, Clear) */
|
|
23
|
+
destructive?: boolean
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/** A visual divider between groups of items. */
|
|
27
|
+
export type ActionSeparator = {
|
|
28
|
+
/** Optional key; falls back to the array index */
|
|
29
|
+
id?: string
|
|
30
|
+
separator: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A muted uppercase section label grouping the items beneath it. */
|
|
34
|
+
export type ActionHeader = {
|
|
35
|
+
/** Optional key; falls back to the array index */
|
|
36
|
+
id?: string
|
|
37
|
+
/** The section label text */
|
|
38
|
+
header: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ActionMenuEntry = ActionItem | ActionSeparator | ActionHeader
|
|
42
|
+
|
|
24
43
|
export type ActionsDropdownProps = {
|
|
25
|
-
/** Items to render inside the dropdown */
|
|
26
|
-
items:
|
|
44
|
+
/** Items to render inside the dropdown (may include separators) */
|
|
45
|
+
items: ActionMenuEntry[]
|
|
27
46
|
/** Button label (default: translated 'Actions') */
|
|
28
47
|
label?: string
|
|
29
48
|
/** Trigger style */
|
|
@@ -32,6 +51,18 @@ export type ActionsDropdownProps = {
|
|
|
32
51
|
ariaLabel?: string
|
|
33
52
|
/** Button size (default: 'sm') */
|
|
34
53
|
size?: 'sm' | 'default'
|
|
54
|
+
/** Extra classes for the trigger button (e.g. to match a dense toolbar row) */
|
|
55
|
+
triggerClassName?: string
|
|
56
|
+
/**
|
|
57
|
+
* Leading icon inside the label-mode trigger. Defaults to `Zap`; pass a
|
|
58
|
+
* component to override, or `false` to render just the label + chevron.
|
|
59
|
+
*/
|
|
60
|
+
triggerIcon?: React.ComponentType<{ className?: string }> | false
|
|
61
|
+
/**
|
|
62
|
+
* Render a small warning dot on the trigger — for when an item inside the
|
|
63
|
+
* menu needs attention (e.g. required fields still blank).
|
|
64
|
+
*/
|
|
65
|
+
attention?: boolean
|
|
35
66
|
}
|
|
36
67
|
|
|
37
68
|
export function ActionsDropdown({
|
|
@@ -40,6 +71,9 @@ export function ActionsDropdown({
|
|
|
40
71
|
triggerMode = 'label',
|
|
41
72
|
ariaLabel,
|
|
42
73
|
size = 'sm',
|
|
74
|
+
triggerClassName,
|
|
75
|
+
triggerIcon,
|
|
76
|
+
attention = false,
|
|
43
77
|
}: ActionsDropdownProps) {
|
|
44
78
|
const t = useT()
|
|
45
79
|
const [open, setOpen] = React.useState(false)
|
|
@@ -133,7 +167,7 @@ export function ActionsDropdown({
|
|
|
133
167
|
type="button"
|
|
134
168
|
variant="outline"
|
|
135
169
|
size={size}
|
|
136
|
-
className={triggerMode === 'icon' ? 'px-2' : undefined}
|
|
170
|
+
className={`${triggerMode === 'icon' ? 'px-2 ' : ''}${triggerClassName ?? ''}`.trim() || undefined}
|
|
137
171
|
aria-haspopup="menu"
|
|
138
172
|
aria-expanded={open}
|
|
139
173
|
aria-label={resolvedAriaLabel}
|
|
@@ -150,11 +184,21 @@ export function ActionsDropdown({
|
|
|
150
184
|
) : (
|
|
151
185
|
<>
|
|
152
186
|
{resolvedLabel}
|
|
153
|
-
|
|
187
|
+
{triggerIcon === false
|
|
188
|
+
? null
|
|
189
|
+
: triggerIcon
|
|
190
|
+
? React.createElement(triggerIcon, { className: 'size-4 ml-1' })
|
|
191
|
+
: <Zap className="size-4 ml-1" />}
|
|
154
192
|
<ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />
|
|
155
193
|
</>
|
|
156
194
|
)}
|
|
157
195
|
</Button>
|
|
196
|
+
{attention && (
|
|
197
|
+
<span
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
className="pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-status-warning-icon ring-2 ring-background"
|
|
200
|
+
/>
|
|
201
|
+
)}
|
|
158
202
|
{open && anchorRect && createPortal(
|
|
159
203
|
<div
|
|
160
204
|
ref={menuRef}
|
|
@@ -168,7 +212,26 @@ export function ActionsDropdown({
|
|
|
168
212
|
transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,
|
|
169
213
|
}}
|
|
170
214
|
>
|
|
171
|
-
{items.map((item) => {
|
|
215
|
+
{items.map((item, index) => {
|
|
216
|
+
if ('header' in item) {
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
key={item.id ?? `header-${index}`}
|
|
220
|
+
className="px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
|
221
|
+
>
|
|
222
|
+
{item.header}
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
if ('separator' in item) {
|
|
227
|
+
return (
|
|
228
|
+
<div
|
|
229
|
+
key={item.id ?? `separator-${index}`}
|
|
230
|
+
role="separator"
|
|
231
|
+
className="my-1 h-px bg-border"
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
172
235
|
const Icon = item.icon
|
|
173
236
|
return (
|
|
174
237
|
<Button
|
|
@@ -176,7 +239,11 @@ export function ActionsDropdown({
|
|
|
176
239
|
type="button"
|
|
177
240
|
variant="ghost"
|
|
178
241
|
size="sm"
|
|
179
|
-
className=
|
|
242
|
+
className={`w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug${
|
|
243
|
+
item.destructive
|
|
244
|
+
? ' text-destructive hover:text-destructive hover:bg-destructive/10 focus-visible:text-destructive'
|
|
245
|
+
: ''
|
|
246
|
+
}`}
|
|
180
247
|
role="menuitem"
|
|
181
248
|
disabled={item.disabled}
|
|
182
249
|
onClick={() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { FormActionButtons, type FormActionButtonsProps } from './FormActionButtons'
|
|
2
|
-
export { ActionsDropdown, type ActionItem, type ActionsDropdownProps } from './ActionsDropdown'
|
|
2
|
+
export { ActionsDropdown, type ActionItem, type ActionSeparator, type ActionHeader, type ActionMenuEntry, type ActionsDropdownProps } from './ActionsDropdown'
|
|
3
3
|
export { FormHeader, type FormHeaderProps, type FormHeaderEditProps, type FormHeaderDetailProps } from './FormHeader'
|
|
4
4
|
export { FormFooter, type FormFooterProps } from './FormFooter'
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
Flag,
|
|
67
67
|
Flame,
|
|
68
68
|
FolderTree,
|
|
69
|
+
FunctionSquare,
|
|
69
70
|
Gauge,
|
|
70
71
|
GitBranch,
|
|
71
72
|
GitCompareArrows,
|
|
@@ -116,6 +117,7 @@ import {
|
|
|
116
117
|
PhoneCall,
|
|
117
118
|
PieChart,
|
|
118
119
|
PlusSquare,
|
|
120
|
+
Radio,
|
|
119
121
|
Receipt,
|
|
120
122
|
ReceiptText,
|
|
121
123
|
RefreshCcw,
|
|
@@ -161,6 +163,7 @@ import {
|
|
|
161
163
|
UserRound,
|
|
162
164
|
Users,
|
|
163
165
|
UserX,
|
|
166
|
+
Variable,
|
|
164
167
|
Wallet,
|
|
165
168
|
Warehouse,
|
|
166
169
|
Webhook,
|
|
@@ -234,6 +237,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
234
237
|
'flag': Flag,
|
|
235
238
|
'flame': Flame,
|
|
236
239
|
'folder-tree': FolderTree,
|
|
240
|
+
'function-square': FunctionSquare,
|
|
237
241
|
'gauge': Gauge,
|
|
238
242
|
'git-branch': GitBranch,
|
|
239
243
|
'git-compare-arrows': GitCompareArrows,
|
|
@@ -284,6 +288,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
284
288
|
'phone-call': PhoneCall,
|
|
285
289
|
'pie-chart': PieChart,
|
|
286
290
|
'plus-square': PlusSquare,
|
|
291
|
+
'radio': Radio,
|
|
287
292
|
'receipt': Receipt,
|
|
288
293
|
'receipt-text': ReceiptText,
|
|
289
294
|
'refresh-ccw': RefreshCcw,
|
|
@@ -329,6 +334,7 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
329
334
|
'user-round': UserRound,
|
|
330
335
|
'user-x': UserX,
|
|
331
336
|
'users': Users,
|
|
337
|
+
'variable': Variable,
|
|
332
338
|
'wallet': Wallet,
|
|
333
339
|
'warehouse': Warehouse,
|
|
334
340
|
'webhook': Webhook,
|
|
@@ -16,6 +16,14 @@ export const BACKEND_TOPBAR_ACTIONS_INJECTION_SPOT_ID: InjectionSpotId = 'backen
|
|
|
16
16
|
export const BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav'
|
|
17
17
|
export const BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID: InjectionSpotId = 'backend:sidebar:nav:footer'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Headless mount point for modules that publish live counts onto sidebar nav
|
|
21
|
+
* items through `setNavBadge`. Rendered once per shell and always — a feeder
|
|
22
|
+
* must keep counting while the sidebar is collapsed or off-screen on mobile —
|
|
23
|
+
* and its widgets are expected to render nothing.
|
|
24
|
+
*/
|
|
25
|
+
export const BACKEND_NAV_BADGES_INJECTION_SPOT_ID: InjectionSpotId = 'backend:nav:badges'
|
|
26
|
+
|
|
19
27
|
// Standardized global status spot ids
|
|
20
28
|
export const GLOBAL_SIDEBAR_STATUS_BADGES_INJECTION_SPOT_ID: InjectionSpotId = 'global:sidebar:status-badges'
|
|
21
29
|
export const GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID: InjectionSpotId = 'global:header:status-indicators'
|