@open-mercato/ui 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8
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/ai/AiAssistantLauncher.js +7 -1
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChatSessions.js +4 -1
- package/dist/ai/AiChatSessions.js.map +2 -2
- package/dist/ai/index.js +8 -0
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiAssistantAvailable.js +32 -0
- package/dist/ai/useAiAssistantAvailable.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +18 -2
- package/dist/backend/detail/addressFormat.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +108 -92
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiAssistantLauncher.tsx +26 -6
- package/src/ai/AiChatSessions.tsx +9 -1
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +67 -0
- package/src/ai/__tests__/AiChatSessions.test.tsx +77 -0
- package/src/ai/__tests__/useAiAssistantAvailable.test.tsx +96 -0
- package/src/ai/index.ts +5 -0
- package/src/ai/useAiAssistantAvailable.ts +50 -0
- package/src/backend/detail/__tests__/addressFormat.taxId.test.ts +33 -0
- package/src/backend/detail/addressFormat.tsx +59 -1
- package/src/backend/inputs/ComboboxInput.tsx +112 -79
- package/src/backend/inputs/__tests__/ComboboxInput.dialog.test.tsx +134 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +4 -1
|
@@ -7,6 +7,7 @@ import { act, screen, waitFor } from '@testing-library/react'
|
|
|
7
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
8
|
import { apiCall } from '../../backend/utils/apiCall'
|
|
9
9
|
import { AiAssistantLauncher, AI_ASSISTANT_LAUNCHER_OPEN_EVENT } from '../AiAssistantLauncher'
|
|
10
|
+
import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
|
|
10
11
|
|
|
11
12
|
jest.mock('next/navigation', () => ({
|
|
12
13
|
useRouter: () => ({ replace: jest.fn() }),
|
|
@@ -18,11 +19,18 @@ jest.mock('../../backend/utils/apiCall', () => ({
|
|
|
18
19
|
apiCall: jest.fn(),
|
|
19
20
|
}))
|
|
20
21
|
|
|
22
|
+
jest.mock('../useAiAssistantAvailable', () => ({
|
|
23
|
+
useAiAssistantAvailable: jest.fn(() => true),
|
|
24
|
+
}))
|
|
25
|
+
|
|
21
26
|
const apiCallMock = apiCall as unknown as jest.Mock
|
|
27
|
+
const aiAvailableMock = useAiAssistantAvailable as jest.MockedFunction<typeof useAiAssistantAvailable>
|
|
22
28
|
|
|
23
29
|
describe('<AiAssistantLauncher>', () => {
|
|
24
30
|
beforeEach(() => {
|
|
25
31
|
apiCallMock.mockReset()
|
|
32
|
+
aiAvailableMock.mockReset()
|
|
33
|
+
aiAvailableMock.mockReturnValue(true)
|
|
26
34
|
apiCallMock.mockImplementation(async (url: string) => {
|
|
27
35
|
if (url === '/api/ai_assistant/health') {
|
|
28
36
|
return { ok: true, result: { healthy: true } }
|
|
@@ -61,4 +69,63 @@ describe('<AiAssistantLauncher>', () => {
|
|
|
61
69
|
expect(await screen.findByRole('dialog', { name: 'AI assistants' })).toBeInTheDocument()
|
|
62
70
|
expect(screen.getByText('Catalog Assistant')).toBeInTheDocument()
|
|
63
71
|
}, 60_000)
|
|
72
|
+
|
|
73
|
+
it('renders nothing and probes no endpoint when the AI assistant is unavailable', async () => {
|
|
74
|
+
aiAvailableMock.mockReturnValue(false)
|
|
75
|
+
|
|
76
|
+
const { container } = renderWithProviders(<AiAssistantLauncher />)
|
|
77
|
+
|
|
78
|
+
expect(container.firstChild).toBeNull()
|
|
79
|
+
|
|
80
|
+
// Give the effects a chance to run before asserting they never did.
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await Promise.resolve()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
86
|
+
expect(screen.queryByRole('button', { name: 'Open AI assistant' })).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('ignores the global launcher shortcut event when the AI assistant is unavailable', async () => {
|
|
90
|
+
aiAvailableMock.mockReturnValue(false)
|
|
91
|
+
|
|
92
|
+
renderWithProviders(<AiAssistantLauncher />)
|
|
93
|
+
|
|
94
|
+
act(() => {
|
|
95
|
+
window.dispatchEvent(new CustomEvent(AI_ASSISTANT_LAUNCHER_OPEN_EVENT))
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await Promise.resolve()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(screen.queryByRole('dialog', { name: 'AI assistants' })).toBeNull()
|
|
103
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The gate is fail-closed until the backend chrome payload arrives, so on an
|
|
107
|
+
// enabled install the launcher always mounts unavailable first and only then
|
|
108
|
+
// flips. The probes must fire on that transition, and only once.
|
|
109
|
+
it('probes exactly once when the gate opens after mount', async () => {
|
|
110
|
+
aiAvailableMock.mockReturnValue(false)
|
|
111
|
+
|
|
112
|
+
const { rerender } = renderWithProviders(<AiAssistantLauncher />)
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
await Promise.resolve()
|
|
116
|
+
})
|
|
117
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
118
|
+
|
|
119
|
+
aiAvailableMock.mockReturnValue(true)
|
|
120
|
+
rerender(<AiAssistantLauncher />)
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getAllByRole('button', { name: 'Open AI assistant' }).length).toBeGreaterThan(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const agentsCalls = apiCallMock.mock.calls.filter(
|
|
127
|
+
([url]) => url === '/api/ai_assistant/ai/agents',
|
|
128
|
+
)
|
|
129
|
+
expect(agentsCalls).toHaveLength(1)
|
|
130
|
+
}, 60_000)
|
|
64
131
|
})
|
|
@@ -24,6 +24,10 @@ jest.mock('../conversation-store', () => {
|
|
|
24
24
|
}
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
jest.mock('../useAiAssistantAvailable', () => ({
|
|
28
|
+
useAiAssistantAvailable: jest.fn(() => true),
|
|
29
|
+
}))
|
|
30
|
+
|
|
27
31
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
28
32
|
const mocked = {
|
|
29
33
|
debug: jest.fn(),
|
|
@@ -41,9 +45,11 @@ import {
|
|
|
41
45
|
listAiServerConversations,
|
|
42
46
|
createAiServerConversation,
|
|
43
47
|
} from '../conversation-store'
|
|
48
|
+
import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
|
|
44
49
|
|
|
45
50
|
const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
|
|
46
51
|
const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
|
|
52
|
+
const aiAvailableMock = useAiAssistantAvailable as jest.MockedFunction<typeof useAiAssistantAvailable>
|
|
47
53
|
const loggerError = createLogger('ui').error as jest.Mock
|
|
48
54
|
const loggerWarn = createLogger('ui').warn as jest.Mock
|
|
49
55
|
|
|
@@ -84,6 +90,8 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
|
|
|
84
90
|
listMock.mockResolvedValue(null)
|
|
85
91
|
createMock.mockReset()
|
|
86
92
|
createMock.mockResolvedValue(null)
|
|
93
|
+
aiAvailableMock.mockReset()
|
|
94
|
+
aiAvailableMock.mockReturnValue(true)
|
|
87
95
|
loggerError.mockClear()
|
|
88
96
|
loggerWarn.mockClear()
|
|
89
97
|
// Reset scope to a known starting point. The module-level state in
|
|
@@ -292,3 +300,72 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
|
|
|
292
300
|
expect(window.localStorage.getItem(scopedKey(null, null))).not.toBeNull()
|
|
293
301
|
})
|
|
294
302
|
})
|
|
303
|
+
|
|
304
|
+
describe('<AiChatSessionsProvider> — ai_assistant availability gate', () => {
|
|
305
|
+
beforeEach(() => {
|
|
306
|
+
window.localStorage.clear()
|
|
307
|
+
listMock.mockReset()
|
|
308
|
+
listMock.mockResolvedValue([])
|
|
309
|
+
createMock.mockReset()
|
|
310
|
+
createMock.mockResolvedValue(null)
|
|
311
|
+
aiAvailableMock.mockReset()
|
|
312
|
+
loggerError.mockClear()
|
|
313
|
+
loggerWarn.mockClear()
|
|
314
|
+
act(() => {
|
|
315
|
+
emitOrganizationScopeChanged({ tenantId: 'T1', organizationId: 'O1' })
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
window.localStorage.clear()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('skips the server conversation sync when the AI assistant is unavailable', async () => {
|
|
324
|
+
aiAvailableMock.mockReturnValue(false)
|
|
325
|
+
|
|
326
|
+
renderWithProviders(<Harness agentId="assistant" />)
|
|
327
|
+
|
|
328
|
+
// Children still render — the provider owns context every backend page needs.
|
|
329
|
+
await waitFor(() => {
|
|
330
|
+
expect(screen.getByTestId('session-count').textContent).toBe('0')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
expect(listMock).not.toHaveBeenCalled()
|
|
334
|
+
expect(loggerWarn).not.toHaveBeenCalled()
|
|
335
|
+
expect(loggerError).not.toHaveBeenCalled()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('syncs once when the AI assistant is available', async () => {
|
|
339
|
+
aiAvailableMock.mockReturnValue(true)
|
|
340
|
+
|
|
341
|
+
renderWithProviders(<Harness agentId="assistant" />)
|
|
342
|
+
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(listMock).toHaveBeenCalledTimes(1)
|
|
345
|
+
})
|
|
346
|
+
expect(listMock).toHaveBeenCalledWith({ limit: 100 })
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// The gate is fail-closed while the backend chrome payload is still null,
|
|
350
|
+
// which is the state at mount on every install. So on an enabled install the
|
|
351
|
+
// sync only ever happens on the re-run triggered by `aiAvailable` flipping
|
|
352
|
+
// true — this pins that dependency.
|
|
353
|
+
it('syncs once the availability gate opens after mount', async () => {
|
|
354
|
+
aiAvailableMock.mockReturnValue(false)
|
|
355
|
+
|
|
356
|
+
const { rerender } = renderWithProviders(<Harness agentId="assistant" />)
|
|
357
|
+
|
|
358
|
+
await waitFor(() => {
|
|
359
|
+
expect(screen.getByTestId('session-count').textContent).toBe('0')
|
|
360
|
+
})
|
|
361
|
+
expect(listMock).not.toHaveBeenCalled()
|
|
362
|
+
|
|
363
|
+
aiAvailableMock.mockReturnValue(true)
|
|
364
|
+
rerender(<Harness agentId="assistant" />)
|
|
365
|
+
|
|
366
|
+
await waitFor(() => {
|
|
367
|
+
expect(listMock).toHaveBeenCalledTimes(1)
|
|
368
|
+
})
|
|
369
|
+
expect(listMock).toHaveBeenCalledWith({ limit: 100 })
|
|
370
|
+
})
|
|
371
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen } from '@testing-library/react'
|
|
7
|
+
import type { BackendChromePayload } from '@open-mercato/shared/modules/navigation/backendChrome'
|
|
8
|
+
import { useBackendChrome } from '../../backend/BackendChromeProvider'
|
|
9
|
+
import { getEnabledModuleIds } from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
10
|
+
import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
|
|
11
|
+
|
|
12
|
+
jest.mock('../../backend/BackendChromeProvider', () => ({
|
|
13
|
+
useBackendChrome: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
|
|
17
|
+
getEnabledModuleIds: jest.fn(),
|
|
18
|
+
subscribeToInjectionRegistryChanges: () => () => {},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const useBackendChromeMock = useBackendChrome as jest.MockedFunction<typeof useBackendChrome>
|
|
22
|
+
const getEnabledModuleIdsMock = getEnabledModuleIds as jest.MockedFunction<typeof getEnabledModuleIds>
|
|
23
|
+
|
|
24
|
+
function Probe() {
|
|
25
|
+
const available = useAiAssistantAvailable()
|
|
26
|
+
return <span data-testid="available">{String(available)}</span>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setChrome(grantedFeatures: string[] | null) {
|
|
30
|
+
useBackendChromeMock.mockReturnValue({
|
|
31
|
+
payload: grantedFeatures === null
|
|
32
|
+
? null
|
|
33
|
+
: ({ grantedFeatures } as unknown as BackendChromePayload),
|
|
34
|
+
isLoading: false,
|
|
35
|
+
isReady: true,
|
|
36
|
+
refresh: async () => {},
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderProbe(): string | null {
|
|
41
|
+
render(<Probe />)
|
|
42
|
+
return screen.getByTestId('available').textContent
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('useAiAssistantAvailable', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
useBackendChromeMock.mockReset()
|
|
48
|
+
getEnabledModuleIdsMock.mockReset()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is true when the module is enabled and the feature is granted', () => {
|
|
52
|
+
getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
|
|
53
|
+
setChrome(['auth.users.view', 'ai_assistant.view'])
|
|
54
|
+
|
|
55
|
+
expect(renderProbe()).toBe('true')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is false when the module is absent from the enabled registry', () => {
|
|
59
|
+
getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'customers']))
|
|
60
|
+
setChrome(['auth.users.view', 'ai_assistant.view'])
|
|
61
|
+
|
|
62
|
+
expect(renderProbe()).toBe('false')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('is false when the feature is not granted', () => {
|
|
66
|
+
getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
|
|
67
|
+
setChrome(['auth.users.view'])
|
|
68
|
+
|
|
69
|
+
expect(renderProbe()).toBe('false')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is false while the backend chrome payload has not arrived', () => {
|
|
73
|
+
getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
|
|
74
|
+
setChrome(null)
|
|
75
|
+
|
|
76
|
+
expect(renderProbe()).toBe('false')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('treats an unregistered module set as "not known yet" rather than disabled', () => {
|
|
80
|
+
// `registerEnabledModuleIds` runs from an async client-bootstrap import, so
|
|
81
|
+
// `null` here means the registry has not landed — not that the module is off.
|
|
82
|
+
getEnabledModuleIdsMock.mockReturnValue(null)
|
|
83
|
+
setChrome(['ai_assistant.view'])
|
|
84
|
+
|
|
85
|
+
expect(renderProbe()).toBe('true')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('matches a module wildcard grant', () => {
|
|
89
|
+
// Superadmins carry `<module>.*`, expanded server-side for enabled modules
|
|
90
|
+
// only. A plain `includes()` would miss it.
|
|
91
|
+
getEnabledModuleIdsMock.mockReturnValue(new Set(['ai_assistant']))
|
|
92
|
+
setChrome(['ai_assistant.*'])
|
|
93
|
+
|
|
94
|
+
expect(renderProbe()).toBe('true')
|
|
95
|
+
})
|
|
96
|
+
})
|
package/src/ai/index.ts
CHANGED
|
@@ -27,6 +27,11 @@ export {
|
|
|
27
27
|
type UseAiChatInput,
|
|
28
28
|
type UseAiChatResult,
|
|
29
29
|
} from './useAiChat'
|
|
30
|
+
export {
|
|
31
|
+
useAiAssistantAvailable,
|
|
32
|
+
AI_ASSISTANT_MODULE_ID,
|
|
33
|
+
AI_ASSISTANT_VIEW_FEATURE,
|
|
34
|
+
} from './useAiAssistantAvailable'
|
|
30
35
|
export {
|
|
31
36
|
useAiShortcuts,
|
|
32
37
|
type UseAiShortcutsOptions,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
getEnabledModuleIds,
|
|
6
|
+
subscribeToInjectionRegistryChanges,
|
|
7
|
+
} from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
8
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
9
|
+
import { useBackendChrome } from '../backend/BackendChromeProvider'
|
|
10
|
+
|
|
11
|
+
export const AI_ASSISTANT_MODULE_ID = 'ai_assistant'
|
|
12
|
+
export const AI_ASSISTANT_VIEW_FEATURE = 'ai_assistant.view'
|
|
13
|
+
|
|
14
|
+
function readEnabledModuleIds(): ReadonlySet<string> | null {
|
|
15
|
+
return getEnabledModuleIds()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readEnabledModuleIdsOnServer(): ReadonlySet<string> | null {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether the AI assistant surfaces should mount at all.
|
|
24
|
+
*
|
|
25
|
+
* Every `/api/ai_assistant/*` route is gated on `ai_assistant.view`, and an
|
|
26
|
+
* installation that does not enable the module has no such routes to answer.
|
|
27
|
+
* Mounting the launcher or the conversation sync in either case only produces
|
|
28
|
+
* 404s / 403s, so both call sites consult this first instead of probing.
|
|
29
|
+
*
|
|
30
|
+
* Two independent signals have to agree:
|
|
31
|
+
* - the client enabled-module registry, populated from the generated
|
|
32
|
+
* `enabled-module-ids.generated.ts` during client bootstrap. It registers
|
|
33
|
+
* asynchronously, so `null` means "not known yet", not "module absent".
|
|
34
|
+
* - `ai_assistant.view` in the backend chrome payload. The server already
|
|
35
|
+
* drops grants owned by disabled modules (and expands a superadmin `*`
|
|
36
|
+
* into enabled modules only), and the payload is fetched once and cached
|
|
37
|
+
* by `BackendChromeProvider`, so this costs no extra request. It stays
|
|
38
|
+
* fail-closed until the payload arrives, which is what keeps the cold-load
|
|
39
|
+
* window quiet.
|
|
40
|
+
*/
|
|
41
|
+
export function useAiAssistantAvailable(): boolean {
|
|
42
|
+
const { payload } = useBackendChrome()
|
|
43
|
+
const enabledModuleIds = React.useSyncExternalStore(
|
|
44
|
+
subscribeToInjectionRegistryChanges,
|
|
45
|
+
readEnabledModuleIds,
|
|
46
|
+
readEnabledModuleIdsOnServer,
|
|
47
|
+
)
|
|
48
|
+
const moduleEnabled = enabledModuleIds === null || enabledModuleIds.has(AI_ASSISTANT_MODULE_ID)
|
|
49
|
+
return moduleEnabled && hasFeature(payload?.grantedFeatures, AI_ASSISTANT_VIEW_FEATURE)
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolveTaxIdLabel } from '../addressFormat'
|
|
2
|
+
|
|
3
|
+
// This module is a documented near-identical twin of `@open-mercato/core`'s `customers/utils/
|
|
4
|
+
// addressFormat`, and the spec forbids letting the two drift. The core copy has these cases; without
|
|
5
|
+
// them here, a change made on one side and forgotten on the other passes CI.
|
|
6
|
+
|
|
7
|
+
describe('resolveTaxIdLabel — the ui twin', () => {
|
|
8
|
+
const BY_TYPE = { plNip: 'NIP', euVat: 'EU VAT', other: 'Tax number' }
|
|
9
|
+
|
|
10
|
+
// The distinction the scheme exists for: `1234567890` and `PL1234567890` are the same business, so
|
|
11
|
+
// one flat label necessarily misnames one of them.
|
|
12
|
+
it('names a domestic identifier and an EU VAT number differently', () => {
|
|
13
|
+
expect(resolveTaxIdLabel(BY_TYPE, 'pl_nip')).toBe('NIP')
|
|
14
|
+
expect(resolveTaxIdLabel(BY_TYPE, 'eu_vat')).toBe('EU VAT')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Naming a foreign number after a domestic scheme renames it, so an unknown scheme takes the
|
|
18
|
+
// neutral label rather than the nearest guess. This is also what lets the vocabulary widen.
|
|
19
|
+
it('falls back to the neutral label for unknown and missing schemes', () => {
|
|
20
|
+
expect(resolveTaxIdLabel(BY_TYPE, 'other')).toBe('Tax number')
|
|
21
|
+
expect(resolveTaxIdLabel(BY_TYPE, 'us_ein')).toBe('Tax number')
|
|
22
|
+
expect(resolveTaxIdLabel(BY_TYPE, null)).toBe('Tax number')
|
|
23
|
+
expect(resolveTaxIdLabel(BY_TYPE, undefined)).toBe('Tax number')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('accepts a plain string, which names every scheme the same', () => {
|
|
27
|
+
expect(resolveTaxIdLabel('Tax ID', 'eu_vat')).toBe('Tax ID')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('has no label to give when the caller supplies none', () => {
|
|
31
|
+
expect(resolveTaxIdLabel(undefined, 'pl_nip')).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -12,6 +12,34 @@ export type AddressValue = {
|
|
|
12
12
|
postalCode?: string | null
|
|
13
13
|
country?: string | null
|
|
14
14
|
companyName?: string | null
|
|
15
|
+
/**
|
|
16
|
+
* Contact details that belong to the ADDRESS rather than to the customer: who to call about this
|
|
17
|
+
* delivery, and the tax identifier this invoice address was billed under. They remain available to
|
|
18
|
+
* address editors and snapshot payloads, but are deliberately excluded from `formatAddressLines`
|
|
19
|
+
* and `AddressView`, whose existing contract remains postal-only.
|
|
20
|
+
*
|
|
21
|
+
* `taxIdType` interprets the value in Stripe's `{country}_{kind}` vocabulary (`pl_nip`, `eu_vat`,
|
|
22
|
+
* `other`, widened additively): `1234567890` and `PL1234567890` are the same business, and only the
|
|
23
|
+
* type tells a domestic identifier from an EU VAT number. It is metadata about `taxId`, never a
|
|
24
|
+
* displayed field of its own.
|
|
25
|
+
*/
|
|
26
|
+
phone?: string | null
|
|
27
|
+
taxId?: string | null
|
|
28
|
+
taxIdType?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tax-id labels keyed by `taxIdType`, for a caller that wants the identifier named correctly rather
|
|
33
|
+
* than generically.
|
|
34
|
+
*
|
|
35
|
+
* The stored scheme is chosen explicitly rather than inferred from the identifier. `other` also
|
|
36
|
+
* covers an address written before `taxIdType` existed. An unrecognised type takes the `other` route
|
|
37
|
+
* instead of guessing a domestic scheme.
|
|
38
|
+
*/
|
|
39
|
+
export type TaxIdLabelByType = {
|
|
40
|
+
plNip: string
|
|
41
|
+
euVat: string
|
|
42
|
+
other: string
|
|
15
43
|
}
|
|
16
44
|
|
|
17
45
|
export type AddressJsonShape = {
|
|
@@ -99,6 +127,31 @@ export function formatAddressString(address: AddressValue, format: AddressFormat
|
|
|
99
127
|
return formatAddressLines(address, format).filter(Boolean).join(separator)
|
|
100
128
|
}
|
|
101
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Which member of a label map names which scheme. Private, and deliberately not exhaustive: an
|
|
132
|
+
* unrecognised type resolves to `other`, so the vocabulary can widen without every caller being
|
|
133
|
+
* updated in the same release.
|
|
134
|
+
*/
|
|
135
|
+
const TAX_ID_LABEL_KEY_BY_TYPE: Record<string, keyof TaxIdLabelByType> = {
|
|
136
|
+
pl_nip: 'plNip',
|
|
137
|
+
eu_vat: 'euVat',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The label a tax identifier should carry, given its type. Exported because the editor renders the
|
|
142
|
+
* same identifier as an input and must name it the same way this formatter does — two copies of the
|
|
143
|
+
* mapping is exactly how a foreign number ends up under a domestic scheme's name.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveTaxIdLabel(
|
|
146
|
+
label: string | TaxIdLabelByType | undefined,
|
|
147
|
+
taxIdType: string | null | undefined,
|
|
148
|
+
): string | undefined {
|
|
149
|
+
if (!label) return undefined
|
|
150
|
+
if (typeof label === 'string') return label
|
|
151
|
+
const key = TAX_ID_LABEL_KEY_BY_TYPE[typeof taxIdType === 'string' ? taxIdType : ''] ?? 'other'
|
|
152
|
+
return label[key]
|
|
153
|
+
}
|
|
154
|
+
|
|
102
155
|
type AddressViewProps = {
|
|
103
156
|
address: AddressValue
|
|
104
157
|
format: AddressFormatStrategy
|
|
@@ -106,7 +159,12 @@ type AddressViewProps = {
|
|
|
106
159
|
lineClassName?: string
|
|
107
160
|
}
|
|
108
161
|
|
|
109
|
-
export function AddressView({
|
|
162
|
+
export function AddressView({
|
|
163
|
+
address,
|
|
164
|
+
format,
|
|
165
|
+
className,
|
|
166
|
+
lineClassName,
|
|
167
|
+
}: AddressViewProps): React.ReactElement | null {
|
|
110
168
|
const lines = formatAddressLines(address, format)
|
|
111
169
|
if (!lines.length) return null
|
|
112
170
|
return (
|