@open-mercato/ui 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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.
Files changed (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -8
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +92 -93
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiIcon.js +17 -0
  8. package/dist/ai/AiIcon.js.map +7 -0
  9. package/dist/ai/LoopDisabledBanner.js +28 -0
  10. package/dist/ai/LoopDisabledBanner.js.map +7 -0
  11. package/dist/ai/LoopTracePanel.js +123 -0
  12. package/dist/ai/LoopTracePanel.js.map +7 -0
  13. package/dist/ai/ModelPicker.js.map +1 -1
  14. package/dist/ai/index.js +8 -0
  15. package/dist/ai/index.js.map +2 -2
  16. package/dist/ai/parts/ConfirmationCard.js +1 -5
  17. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  18. package/dist/ai/parts/FieldDiffCard.js +8 -15
  19. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  20. package/dist/ai/parts/MutationResultCard.js +1 -4
  21. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  22. package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
  23. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  24. package/dist/ai/useAiChat.js +18 -4
  25. package/dist/ai/useAiChat.js.map +2 -2
  26. package/dist/backend/CrudForm.js +24 -2
  27. package/dist/backend/CrudForm.js.map +2 -2
  28. package/dist/backend/DataTable.js +2 -2
  29. package/dist/backend/DataTable.js.map +2 -2
  30. package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
  31. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  32. package/dist/backend/detail/CustomDataSection.js +22 -3
  33. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  34. package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
  35. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  36. package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
  37. package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
  38. package/dist/backend/inputs/ComboboxInput.js +25 -7
  39. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  40. package/dist/backend/progress/useProgressPoll.js +58 -3
  41. package/dist/backend/progress/useProgressPoll.js.map +2 -2
  42. package/dist/backend/progress/useProgressSse.js +15 -2
  43. package/dist/backend/progress/useProgressSse.js.map +2 -2
  44. package/dist/backend/utils/bulkDelete.js +76 -0
  45. package/dist/backend/utils/bulkDelete.js.map +2 -2
  46. package/package.json +11 -6
  47. package/src/ai/AiAssistantLauncher.tsx +8 -8
  48. package/src/ai/AiChat.tsx +44 -38
  49. package/src/ai/AiIcon.tsx +12 -0
  50. package/src/ai/LoopDisabledBanner.tsx +38 -0
  51. package/src/ai/LoopTracePanel.tsx +184 -0
  52. package/src/ai/ModelPicker.tsx +1 -1
  53. package/src/ai/__tests__/AiChat.test.tsx +174 -5
  54. package/src/ai/index.ts +8 -0
  55. package/src/ai/parts/ConfirmationCard.tsx +1 -5
  56. package/src/ai/parts/FieldDiffCard.tsx +0 -3
  57. package/src/ai/parts/MutationResultCard.tsx +1 -4
  58. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
  59. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
  60. package/src/ai/useAiChat.ts +35 -5
  61. package/src/backend/CrudForm.tsx +30 -3
  62. package/src/backend/DataTable.tsx +2 -2
  63. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
  64. package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
  65. package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
  66. package/src/backend/__tests__/component-replacement.test.tsx +44 -0
  67. package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
  68. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
  69. package/src/backend/detail/CustomDataSection.tsx +26 -3
  70. package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
  71. package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
  72. package/src/backend/inputs/ComboboxInput.tsx +29 -7
  73. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
  74. package/src/backend/progress/useProgressPoll.ts +68 -3
  75. package/src/backend/progress/useProgressSse.ts +16 -2
  76. package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
  77. package/src/backend/utils/bulkDelete.ts +93 -0
@@ -38,9 +38,10 @@ describe('ComboboxInput — eager label resolution', () => {
38
38
  expect(getInput(container).value).toBe('uuid-123')
39
39
  await waitFor(() => expect(getInput(container).value).toBe('Resolved uuid-123'))
40
40
  expect(resolveLabel).toHaveBeenCalledWith('uuid-123')
41
+ expect(resolveLabel).toHaveBeenCalledTimes(1)
41
42
  })
42
43
 
43
- it('resolves the label via a synchronous resolveLabel', () => {
44
+ it('resolves the label via a synchronous resolveLabel', async () => {
44
45
  const { container } = render(
45
46
  <ComboboxInput
46
47
  value="uuid-123"
@@ -48,7 +49,7 @@ describe('ComboboxInput — eager label resolution', () => {
48
49
  resolveLabel={(value) => (value === 'uuid-123' ? 'Sync Label' : value)}
49
50
  />,
50
51
  )
51
- expect(getInput(container).value).toBe('Sync Label')
52
+ await waitFor(() => expect(getInput(container).value).toBe('Sync Label'))
52
53
  })
53
54
 
54
55
  it('does not call resolveLabel when the value is already covered by suggestions', () => {
@@ -73,6 +74,36 @@ describe('ComboboxInput — eager label resolution', () => {
73
74
  expect(loadSuggestions).toHaveBeenCalledWith()
74
75
  })
75
76
 
77
+ it('does not reload suggestions after a self-labeled value is covered', async () => {
78
+ const loadSuggestions = jest.fn(async () => [{ value: 'UTC', label: 'UTC' }])
79
+ const { container } = render(
80
+ <ComboboxInput value="UTC" onChange={() => {}} loadSuggestions={loadSuggestions} />,
81
+ )
82
+
83
+ await waitFor(() => expect(loadSuggestions).toHaveBeenCalledTimes(1))
84
+ await act(async () => {
85
+ await new Promise((resolve) => setTimeout(resolve, 25))
86
+ })
87
+
88
+ expect(loadSuggestions).toHaveBeenCalledTimes(1)
89
+ expect(getInput(container).value).toBe('UTC')
90
+ })
91
+
92
+ it('does not loop when the first suggestions page cannot resolve the value', async () => {
93
+ const loadSuggestions = jest.fn(async () => [{ value: 'Europe/Warsaw', label: 'Europe/Warsaw' }])
94
+ const { container } = render(
95
+ <ComboboxInput value="UTC" onChange={() => {}} loadSuggestions={loadSuggestions} />,
96
+ )
97
+
98
+ await waitFor(() => expect(loadSuggestions).toHaveBeenCalledTimes(1))
99
+ await act(async () => {
100
+ await new Promise((resolve) => setTimeout(resolve, 25))
101
+ })
102
+
103
+ expect(loadSuggestions).toHaveBeenCalledTimes(1)
104
+ expect(getInput(container).value).toBe('UTC')
105
+ })
106
+
76
107
  it('keeps the resolved label after a blur revert when custom values are disallowed', async () => {
77
108
  const onChange = jest.fn()
78
109
  const { container } = render(
@@ -4,6 +4,7 @@ import { apiCall } from '../utils/apiCall'
4
4
  import {
5
5
  subscribeProgressUpdate,
6
6
  subscribeProgressComplete,
7
+ type ProgressUpdateDetail,
7
8
  } from '@open-mercato/shared/lib/frontend/progressEvents'
8
9
 
9
10
  export type ProgressJobDto = {
@@ -37,6 +38,62 @@ function isVisibleProgressJob(job: ProgressJobDto): boolean {
37
38
  return job.meta?.hiddenFromTopBar !== true
38
39
  }
39
40
 
41
+ export function isLocalProgressJob(job: ProgressJobDto): boolean {
42
+ return job.id.startsWith('client:')
43
+ }
44
+
45
+ function isTerminalStatus(status: ProgressJobDto['status']): boolean {
46
+ return status === 'completed' || status === 'failed' || status === 'cancelled'
47
+ }
48
+
49
+ function localJobFromProgressDetail(detail: ProgressUpdateDetail): ProgressJobDto {
50
+ const now = new Date().toISOString()
51
+ const status = detail.status
52
+ return {
53
+ id: detail.jobId,
54
+ jobType: detail.jobType,
55
+ name: detail.name,
56
+ description: detail.description ?? null,
57
+ meta: detail.meta ?? null,
58
+ status,
59
+ progressPercent: detail.progressPercent,
60
+ processedCount: detail.processedCount,
61
+ totalCount: detail.totalCount ?? null,
62
+ etaSeconds: detail.etaSeconds ?? null,
63
+ cancellable: detail.cancellable ?? false,
64
+ startedAt: detail.startedAt ?? now,
65
+ finishedAt: detail.finishedAt ?? (isTerminalStatus(status) ? now : null),
66
+ errorMessage: detail.errorMessage ?? null,
67
+ }
68
+ }
69
+
70
+ function upsertLocalJob(list: ProgressJobDto[], job: ProgressJobDto): ProgressJobDto[] {
71
+ if (!isVisibleProgressJob(job)) {
72
+ return list.filter((item) => item.id !== job.id)
73
+ }
74
+ const next = [job, ...list.filter((item) => item.id !== job.id)]
75
+ return next.sort(
76
+ (a, b) =>
77
+ new Date(b.startedAt ?? b.finishedAt ?? 0).getTime()
78
+ - new Date(a.startedAt ?? a.finishedAt ?? 0).getTime(),
79
+ )
80
+ }
81
+
82
+ export function applyLocalProgressUpdate(
83
+ detail: ProgressUpdateDetail,
84
+ setActiveJobs: React.Dispatch<React.SetStateAction<ProgressJobDto[]>>,
85
+ setRecentlyCompleted: React.Dispatch<React.SetStateAction<ProgressJobDto[]>>,
86
+ ): void {
87
+ const job = localJobFromProgressDetail(detail)
88
+ if (isTerminalStatus(job.status)) {
89
+ setActiveJobs((prev) => prev.filter((item) => item.id !== job.id))
90
+ setRecentlyCompleted((prev) => upsertLocalJob(prev, job).slice(0, 10))
91
+ return
92
+ }
93
+ setRecentlyCompleted((prev) => prev.filter((item) => item.id !== job.id))
94
+ setActiveJobs((prev) => upsertLocalJob(prev, job).slice(0, 50))
95
+ }
96
+
40
97
  export function useProgressPoll(): UseProgressPollResult {
41
98
  const [activeJobs, setActiveJobs] = React.useState<ProgressJobDto[]>([])
42
99
  const [recentlyCompleted, setRecentlyCompleted] = React.useState<ProgressJobDto[]>([])
@@ -49,8 +106,14 @@ export function useProgressPoll(): UseProgressPollResult {
49
106
  '/api/progress/active'
50
107
  )
51
108
  if (result.ok && result.result) {
52
- setActiveJobs(result.result.active.filter(isVisibleProgressJob))
53
- setRecentlyCompleted(result.result.recentlyCompleted.filter(isVisibleProgressJob))
109
+ setActiveJobs((prev) => [
110
+ ...prev.filter((job) => isLocalProgressJob(job) && !isTerminalStatus(job.status)),
111
+ ...result.result!.active.filter(isVisibleProgressJob),
112
+ ])
113
+ setRecentlyCompleted((prev) => [
114
+ ...prev.filter((job) => isLocalProgressJob(job) && isTerminalStatus(job.status)),
115
+ ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),
116
+ ].slice(0, 10))
54
117
  setError(null)
55
118
  }
56
119
  } catch (err) {
@@ -88,7 +151,9 @@ export function useProgressPoll(): UseProgressPollResult {
88
151
  }, [fetchJobs])
89
152
 
90
153
  React.useEffect(() => {
91
- const unsubUpdate = subscribeProgressUpdate(() => refresh())
154
+ const unsubUpdate = subscribeProgressUpdate((detail) => {
155
+ applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)
156
+ })
92
157
  const unsubComplete = subscribeProgressComplete(() => refresh())
93
158
  return () => {
94
159
  unsubUpdate()
@@ -2,7 +2,9 @@
2
2
  import * as React from 'react'
3
3
  import { apiCall } from '../utils/apiCall'
4
4
  import { useAppEvent } from '../injection/useAppEvent'
5
+ import { subscribeProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'
5
6
  import type { ProgressJobDto, UseProgressPollResult } from './useProgressPoll'
7
+ import { applyLocalProgressUpdate, isLocalProgressJob } from './useProgressPoll'
6
8
 
7
9
  function isVisibleProgressJob(job: ProgressJobDto): boolean {
8
10
  return job.meta?.hiddenFromTopBar !== true
@@ -32,8 +34,14 @@ export function useProgressSse(): UseProgressPollResult {
32
34
  '/api/progress/active',
33
35
  )
34
36
  if (result.ok && result.result) {
35
- setActiveJobs(result.result.active.filter(isVisibleProgressJob))
36
- setRecentlyCompleted(result.result.recentlyCompleted.filter(isVisibleProgressJob))
37
+ setActiveJobs((prev) => [
38
+ ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'pending' || job.status === 'running')),
39
+ ...result.result!.active.filter(isVisibleProgressJob),
40
+ ])
41
+ setRecentlyCompleted((prev) => [
42
+ ...prev.filter((job) => isLocalProgressJob(job) && (job.status === 'completed' || job.status === 'failed')),
43
+ ...result.result!.recentlyCompleted.filter(isVisibleProgressJob),
44
+ ].slice(0, 10))
37
45
  setError(null)
38
46
  }
39
47
  } catch (err) {
@@ -51,6 +59,12 @@ export function useProgressSse(): UseProgressPollResult {
51
59
  void fetchJobs()
52
60
  }, [fetchJobs])
53
61
 
62
+ React.useEffect(() => {
63
+ return subscribeProgressUpdate((detail) => {
64
+ applyLocalProgressUpdate(detail, setActiveJobs, setRecentlyCompleted)
65
+ })
66
+ }, [])
67
+
54
68
  useAppEvent(
55
69
  'progress.job.updated',
56
70
  (event) => {
@@ -1,6 +1,15 @@
1
1
  import { groupBulkDeleteFailures, runBulkDelete, type BulkDeleteFailure } from '../bulkDelete'
2
+ import { emitProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'
3
+
4
+ jest.mock('@open-mercato/shared/lib/frontend/progressEvents', () => ({
5
+ emitProgressUpdate: jest.fn(),
6
+ }))
2
7
 
3
8
  describe('runBulkDelete', () => {
9
+ beforeEach(() => {
10
+ jest.mocked(emitProgressUpdate).mockClear()
11
+ })
12
+
4
13
  it('captures error message and code from thrown error', async () => {
5
14
  const dependencyErr = Object.assign(new Error('Cannot delete company: linked deals (2).'), {
6
15
  code: 'COMPANY_HAS_DEPENDENTS',
@@ -66,6 +75,39 @@ describe('runBulkDelete', () => {
66
75
  warnSpy.mockRestore()
67
76
  }
68
77
  })
78
+
79
+ it('emits client progress events when progress metadata is provided', async () => {
80
+ const rows = [{ id: 'a' }, { id: 'b' }]
81
+ const deleteOne = jest.fn().mockResolvedValue(undefined)
82
+
83
+ await runBulkDelete(rows, deleteOne, {
84
+ progress: {
85
+ jobId: 'client:test',
86
+ jobType: 'customers.people.bulk_delete',
87
+ name: 'Delete selected people',
88
+ },
89
+ })
90
+
91
+ const calls = jest.mocked(emitProgressUpdate).mock.calls.map(([detail]) => detail)
92
+ expect(calls).toHaveLength(4)
93
+ expect(calls[0]).toMatchObject({
94
+ jobId: 'client:test',
95
+ status: 'running',
96
+ processedCount: 0,
97
+ totalCount: 2,
98
+ progressPercent: 0,
99
+ })
100
+ expect(calls[2]).toMatchObject({
101
+ status: 'running',
102
+ processedCount: 2,
103
+ progressPercent: 100,
104
+ })
105
+ expect(calls[3]).toMatchObject({
106
+ status: 'completed',
107
+ processedCount: 2,
108
+ progressPercent: 100,
109
+ })
110
+ })
69
111
  })
70
112
 
71
113
  describe('groupBulkDeleteFailures', () => {
@@ -1,5 +1,7 @@
1
1
  "use client"
2
2
 
3
+ import { emitProgressUpdate } from '@open-mercato/shared/lib/frontend/progressEvents'
4
+
3
5
  export type BulkDeleteFailure = {
4
6
  id: string
5
7
  code: string | null
@@ -14,6 +16,35 @@ export type BulkDeleteOutcome<T extends { id: string }> = {
14
16
  export type BulkDeleteOptions = {
15
17
  fallbackErrorMessage?: string
16
18
  logTag?: string
19
+ progress?: {
20
+ jobId?: string
21
+ jobType: string
22
+ name: string
23
+ description?: string | null
24
+ meta?: Record<string, unknown> | null
25
+ }
26
+ }
27
+
28
+ function createClientProgressJobId(jobType: string): string {
29
+ const cryptoRef =
30
+ typeof globalThis !== 'undefined'
31
+ ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
32
+ : undefined
33
+ if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {
34
+ return `client:${cryptoRef.randomUUID()}`
35
+ }
36
+ return `client:${jobType}:${Date.now()}:${Math.random().toString(36).slice(2)}`
37
+ }
38
+
39
+ function calculateProgress(processed: number, total: number): number {
40
+ if (total <= 0) return 100
41
+ return Math.max(0, Math.min(100, Math.round((processed / total) * 100)))
42
+ }
43
+
44
+ function calculateEtaSeconds(startedAtMs: number, processed: number, total: number): number | null {
45
+ if (processed <= 0 || processed >= total) return null
46
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAtMs) / 1000))
47
+ return Math.ceil((elapsedSeconds / processed) * (total - processed))
17
48
  }
18
49
 
19
50
  export async function runBulkDelete<T extends { id: string }>(
@@ -25,6 +56,26 @@ export async function runBulkDelete<T extends { id: string }>(
25
56
  const logTag = options?.logTag
26
57
  const succeeded: T[] = []
27
58
  const failures: BulkDeleteFailure[] = []
59
+ const progress = options?.progress
60
+ const progressJobId = progress && rows.length > 0 ? progress.jobId ?? createClientProgressJobId(progress.jobType) : null
61
+ const startedAtMs = Date.now()
62
+ const startedAt = new Date(startedAtMs).toISOString()
63
+ if (progress && progressJobId) {
64
+ emitProgressUpdate({
65
+ jobId: progressJobId,
66
+ jobType: progress.jobType,
67
+ name: progress.name,
68
+ description: progress.description ?? null,
69
+ meta: progress.meta ?? null,
70
+ status: 'running',
71
+ progressPercent: 0,
72
+ processedCount: 0,
73
+ totalCount: rows.length,
74
+ etaSeconds: null,
75
+ cancellable: false,
76
+ startedAt,
77
+ })
78
+ }
28
79
  for (const row of rows) {
29
80
  try {
30
81
  await deleteOne(row)
@@ -40,6 +91,48 @@ export async function runBulkDelete<T extends { id: string }>(
40
91
  console.warn(`[${logTag}] bulk delete failed`, { id: row.id, code, message, err })
41
92
  }
42
93
  }
94
+ if (progress && progressJobId) {
95
+ const processed = succeeded.length + failures.length
96
+ emitProgressUpdate({
97
+ jobId: progressJobId,
98
+ jobType: progress.jobType,
99
+ name: progress.name,
100
+ description: progress.description ?? null,
101
+ meta: progress.meta ?? null,
102
+ status: 'running',
103
+ progressPercent: calculateProgress(processed, rows.length),
104
+ processedCount: processed,
105
+ totalCount: rows.length,
106
+ etaSeconds: calculateEtaSeconds(startedAtMs, processed, rows.length),
107
+ cancellable: false,
108
+ startedAt,
109
+ })
110
+ }
111
+ }
112
+ if (progress && progressJobId) {
113
+ emitProgressUpdate({
114
+ jobId: progressJobId,
115
+ jobType: progress.jobType,
116
+ name: progress.name,
117
+ description: progress.description ?? null,
118
+ meta: {
119
+ ...(progress.meta ?? {}),
120
+ succeededCount: succeeded.length,
121
+ failedCount: failures.length,
122
+ },
123
+ status: failures.length === rows.length && rows.length > 0 ? 'failed' : 'completed',
124
+ progressPercent: 100,
125
+ processedCount: rows.length,
126
+ totalCount: rows.length,
127
+ etaSeconds: 0,
128
+ cancellable: false,
129
+ startedAt,
130
+ finishedAt: new Date().toISOString(),
131
+ errorMessage:
132
+ failures.length === rows.length && failures[0]?.message
133
+ ? failures[0].message
134
+ : null,
135
+ })
43
136
  }
44
137
  return { succeeded, failures }
45
138
  }