@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
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/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -3,6 +3,7 @@ import { FileCode, Globe, Lock } from 'lucide-react'
|
|
|
3
3
|
import { type CrudField } from '../CrudForm'
|
|
4
4
|
import { IconButton } from '../../primitives/icon-button'
|
|
5
5
|
import { Input } from '../../primitives/input'
|
|
6
|
+
import { EmailInput } from '../../primitives/email-input'
|
|
6
7
|
import { Label } from '../../primitives/label'
|
|
7
8
|
import {
|
|
8
9
|
Select,
|
|
@@ -191,9 +192,8 @@ function ComposeModeFields({ compose }: ComposeProps) {
|
|
|
191
192
|
{compose.visibility === 'public' ? (
|
|
192
193
|
<>
|
|
193
194
|
<Label htmlFor="messages-compose-external-email">{compose.t('messages.externalEmail', 'External email')}</Label>
|
|
194
|
-
<
|
|
195
|
+
<EmailInput
|
|
195
196
|
id="messages-compose-external-email"
|
|
196
|
-
type="email"
|
|
197
197
|
value={compose.externalEmail}
|
|
198
198
|
onChange={(event) => compose.setExternalEmail(event.target.value)}
|
|
199
199
|
placeholder={compose.t('messages.placeholders.externalEmail', 'name@example.com')}
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -23,6 +23,23 @@ function readRedirectOverride(headers: Headers, headerName: string): boolean {
|
|
|
23
23
|
return headers.get(headerName) === '0'
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function isSameOriginRequest(input: RequestInfo | URL): boolean {
|
|
27
|
+
if (typeof window === 'undefined') return false
|
|
28
|
+
const host = window.location?.host
|
|
29
|
+
if (!host) return false
|
|
30
|
+
let urlString: string
|
|
31
|
+
if (typeof input === 'string') urlString = input
|
|
32
|
+
else if (input instanceof URL) urlString = input.toString()
|
|
33
|
+
else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url
|
|
34
|
+
else return false
|
|
35
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true
|
|
36
|
+
try {
|
|
37
|
+
return new URL(urlString).host === host
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
export async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {
|
|
27
44
|
return scopedHeaders.withScopedHeaders(headers, run)
|
|
28
45
|
}
|
|
@@ -109,9 +126,17 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
109
126
|
)
|
|
110
127
|
}
|
|
111
128
|
const scoped = scopedHeaders.resolveScopedHeaders()
|
|
112
|
-
const
|
|
129
|
+
const baseInit: RequestInit = Object.keys(scoped).length
|
|
113
130
|
? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
|
|
114
|
-
: init
|
|
131
|
+
: init ?? {}
|
|
132
|
+
// Only auto-inject credentials: 'include' for same-origin requests so cookies
|
|
133
|
+
// round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)
|
|
134
|
+
// without leaking session cookies to third-party hosts.
|
|
135
|
+
const mergedInit: RequestInit = baseInit.credentials
|
|
136
|
+
? baseInit
|
|
137
|
+
: isSameOriginRequest(input)
|
|
138
|
+
? { ...baseInit, credentials: 'include' }
|
|
139
|
+
: baseInit
|
|
115
140
|
const requestHeaders = new Headers(mergedInit?.headers)
|
|
116
141
|
const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
|
|
117
142
|
const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
|
|
@@ -124,12 +124,19 @@ export function normalizeCrudServerError(err: unknown): NormalizedCrudServerErro
|
|
|
124
124
|
|
|
125
125
|
if (typeof current !== 'object') continue
|
|
126
126
|
|
|
127
|
+
const errorField = (current as any).error
|
|
128
|
+
const nestedErrorMessage =
|
|
129
|
+
errorField && typeof errorField === 'object' &&
|
|
130
|
+
typeof (errorField as any).message === 'string' &&
|
|
131
|
+
(errorField as any).message.trim()
|
|
132
|
+
? ((errorField as any).message as string)
|
|
133
|
+
: undefined
|
|
127
134
|
const candidateMessage =
|
|
128
|
-
typeof
|
|
129
|
-
?
|
|
135
|
+
typeof errorField === 'string'
|
|
136
|
+
? errorField
|
|
130
137
|
: typeof (current as any).message === 'string'
|
|
131
138
|
? (current as any).message
|
|
132
|
-
:
|
|
139
|
+
: nestedErrorMessage
|
|
133
140
|
if (candidateMessage && !message) message = candidateMessage
|
|
134
141
|
|
|
135
142
|
for (const key of JSON_FIELD_KEYS) {
|
|
@@ -266,12 +273,12 @@ export async function raiseCrudError(res: Response, fallbackMessage?: string): P
|
|
|
266
273
|
|
|
267
274
|
if (parsed && typeof parsed === 'object') {
|
|
268
275
|
const data = parsed as Record<string, unknown>
|
|
276
|
+
const normalized = normalizeCrudServerError(data)
|
|
277
|
+
const normalizedMessage = normalized.message?.trim()
|
|
269
278
|
const rawMessage =
|
|
270
|
-
|
|
271
|
-
?
|
|
272
|
-
:
|
|
273
|
-
? data.message.trim()
|
|
274
|
-
: fallbackMessage ?? `Request failed (${res.status})`
|
|
279
|
+
normalizedMessage && normalizedMessage.length > 0
|
|
280
|
+
? normalizedMessage
|
|
281
|
+
: fallbackMessage ?? `Request failed (${res.status})`
|
|
275
282
|
const message = parseServerMessage(rawMessage)
|
|
276
283
|
throw buildHttpError(message, {
|
|
277
284
|
...data,
|
|
@@ -17,6 +17,7 @@ type ColumnMeta = {
|
|
|
17
17
|
filterLoadOptions?: (query?: string) => Promise<FilterOption[]>
|
|
18
18
|
filterable?: boolean
|
|
19
19
|
filterGroup?: string
|
|
20
|
+
filterIconName?: string
|
|
20
21
|
columnChooserGroup?: string
|
|
21
22
|
alwaysVisible?: boolean
|
|
22
23
|
hidden?: boolean
|
|
@@ -85,6 +86,7 @@ export function useAutoDiscoveredFields({
|
|
|
85
86
|
type,
|
|
86
87
|
group: meta?.filterGroup ?? meta?.columnChooserGroup,
|
|
87
88
|
}
|
|
89
|
+
if (meta?.filterIconName) field.iconName = meta.filterIconName
|
|
88
90
|
if (meta?.filterOptions) field.options = meta.filterOptions
|
|
89
91
|
if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions
|
|
90
92
|
filterFields.push(field)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { apiCall } from './apiCall'
|
|
4
|
+
|
|
5
|
+
type FeatureCheckResponse = {
|
|
6
|
+
ok: boolean
|
|
7
|
+
granted: string[]
|
|
8
|
+
userId: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read the current user's id on the client. Returns `''` until the request resolves.
|
|
13
|
+
*
|
|
14
|
+
* Implementation note: there is no first-class `/api/auth/me` endpoint yet, so this
|
|
15
|
+
* piggy-backs on `/api/auth/feature-check` with an empty `features` array — the response
|
|
16
|
+
* always carries the JWT `userId`. Replace with a dedicated `/api/auth/me` or an
|
|
17
|
+
* SSR-bootstrapped user context once available, and remove this hop.
|
|
18
|
+
*/
|
|
19
|
+
export function useCurrentUserId(): string {
|
|
20
|
+
const [userId, setUserId] = React.useState<string>('')
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
let cancelled = false
|
|
23
|
+
void (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ features: [] }),
|
|
29
|
+
})
|
|
30
|
+
if (!cancelled && res.ok && typeof res.result?.userId === 'string') {
|
|
31
|
+
setUserId(res.result.userId)
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
if (!cancelled) setUserId('')
|
|
35
|
+
}
|
|
36
|
+
})()
|
|
37
|
+
return () => { cancelled = true }
|
|
38
|
+
}, [])
|
|
39
|
+
return userId
|
|
40
|
+
}
|
|
@@ -135,6 +135,86 @@ describe('buildPortalNav', () => {
|
|
|
135
135
|
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
+
it('dedupes manifest entries that share the same portal pattern', () => {
|
|
139
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
140
|
+
makeRoute({
|
|
141
|
+
moduleId: 'core',
|
|
142
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
143
|
+
nav: { label: 'Dashboard', group: 'main', order: 10 },
|
|
144
|
+
}),
|
|
145
|
+
makeRoute({
|
|
146
|
+
moduleId: 'app',
|
|
147
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
148
|
+
nav: { label: 'Dashboard', group: 'main', order: 10 },
|
|
149
|
+
}),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
153
|
+
const main = groups.find((g) => g.id === 'main')!
|
|
154
|
+
expect(main.items).toHaveLength(1)
|
|
155
|
+
const ids = main.items.map((i) => i.id)
|
|
156
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('prefers the override with non-empty requireCustomerFeatures over a broad fallback', () => {
|
|
160
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
161
|
+
makeRoute({
|
|
162
|
+
moduleId: 'core',
|
|
163
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
164
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
165
|
+
}),
|
|
166
|
+
makeRoute({
|
|
167
|
+
moduleId: 'app',
|
|
168
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
169
|
+
requireCustomerFeatures: ['portal.dashboard.view'],
|
|
170
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
171
|
+
}),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
const without = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
175
|
+
expect(without).toEqual([])
|
|
176
|
+
|
|
177
|
+
const withGrant = buildPortalNav({
|
|
178
|
+
routes,
|
|
179
|
+
orgSlug: 'my-org',
|
|
180
|
+
grantedFeatures: ['portal.dashboard.view'],
|
|
181
|
+
})
|
|
182
|
+
expect(withGrant).toEqual([
|
|
183
|
+
{ id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
|
|
184
|
+
])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('prefers the override with the longer requireCustomerFeatures list', () => {
|
|
188
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
189
|
+
makeRoute({
|
|
190
|
+
moduleId: 'core',
|
|
191
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
192
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
193
|
+
nav: { label: 'Orders', group: 'main' },
|
|
194
|
+
}),
|
|
195
|
+
makeRoute({
|
|
196
|
+
moduleId: 'app',
|
|
197
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
198
|
+
requireCustomerFeatures: ['portal.orders.view', 'portal.orders.manage'],
|
|
199
|
+
nav: { label: 'Orders', group: 'main' },
|
|
200
|
+
}),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
const partial = buildPortalNav({
|
|
204
|
+
routes,
|
|
205
|
+
orgSlug: 'my-org',
|
|
206
|
+
grantedFeatures: ['portal.orders.view'],
|
|
207
|
+
})
|
|
208
|
+
expect(partial).toEqual([])
|
|
209
|
+
|
|
210
|
+
const full = buildPortalNav({
|
|
211
|
+
routes,
|
|
212
|
+
orgSlug: 'my-org',
|
|
213
|
+
grantedFeatures: ['portal.orders.view', 'portal.orders.manage'],
|
|
214
|
+
})
|
|
215
|
+
expect(full[0].items[0].label).toBe('Orders')
|
|
216
|
+
})
|
|
217
|
+
|
|
138
218
|
it('sorts items by order then label', () => {
|
|
139
219
|
const routes: FrontendRouteManifestEntry[] = [
|
|
140
220
|
makeRoute({
|
package/src/portal/utils/nav.ts
CHANGED
|
@@ -46,6 +46,20 @@ function pickGroup(group: unknown): PortalNavGroupId {
|
|
|
46
46
|
return 'main'
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function pickPreferredRoute(
|
|
50
|
+
existing: FrontendRouteManifestEntry,
|
|
51
|
+
candidate: FrontendRouteManifestEntry,
|
|
52
|
+
): FrontendRouteManifestEntry {
|
|
53
|
+
const existingFeatures = existing.requireCustomerFeatures ?? []
|
|
54
|
+
const candidateFeatures = candidate.requireCustomerFeatures ?? []
|
|
55
|
+
if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate
|
|
56
|
+
if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing
|
|
57
|
+
if (existingFeatures.length !== candidateFeatures.length) {
|
|
58
|
+
return candidateFeatures.length > existingFeatures.length ? candidate : existing
|
|
59
|
+
}
|
|
60
|
+
return existing
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Build the portal sidebar from the frontend route manifest.
|
|
51
65
|
*
|
|
@@ -66,19 +80,27 @@ export function buildPortalNav({
|
|
|
66
80
|
const mainItems: PortalNavItem[] = []
|
|
67
81
|
const accountItems: PortalNavItem[] = []
|
|
68
82
|
|
|
83
|
+
const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()
|
|
69
84
|
for (const route of routes) {
|
|
70
85
|
const pattern = route.pattern ?? route.path
|
|
71
86
|
if (!isPortalPattern(pattern)) continue
|
|
72
87
|
if (route.navHidden) continue
|
|
73
88
|
const nav = route.nav
|
|
74
89
|
if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue
|
|
90
|
+
const existing = dedupedByPattern.get(pattern)
|
|
91
|
+
dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const route of dedupedByPattern.values()) {
|
|
95
|
+
const pattern = (route.pattern ?? route.path) as string
|
|
96
|
+
const nav = route.nav!
|
|
75
97
|
|
|
76
98
|
const requireFeatures = route.requireCustomerFeatures ?? []
|
|
77
99
|
if (!isPortalAdmin && requireFeatures.length) {
|
|
78
100
|
if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
|
|
79
101
|
}
|
|
80
102
|
|
|
81
|
-
const href = resolveHref(pattern
|
|
103
|
+
const href = resolveHref(pattern, orgSlug)
|
|
82
104
|
if (!hasNoUnresolvedParams(href)) continue
|
|
83
105
|
|
|
84
106
|
const group = pickGroup(nav.group)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import * as React from 'react'
|
|
8
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import { PasswordInput } from '../password-input'
|
|
10
|
+
|
|
11
|
+
describe('PasswordInput primitive', () => {
|
|
12
|
+
it('renders the inner input as type="password" by default', () => {
|
|
13
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
14
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
15
|
+
expect(input).not.toBeNull()
|
|
16
|
+
expect(input.type).toBe('password')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('toggles the input type when the reveal button is clicked', () => {
|
|
20
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
21
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
22
|
+
const toggle = screen.getByRole('button', { name: 'Show password' })
|
|
23
|
+
expect(input.type).toBe('password')
|
|
24
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
25
|
+
|
|
26
|
+
fireEvent.click(toggle)
|
|
27
|
+
|
|
28
|
+
expect(input.type).toBe('text')
|
|
29
|
+
expect(screen.getByRole('button', { name: 'Hide password' })).toHaveAttribute('aria-pressed', 'true')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('respects the controlled reveal state via revealed prop', () => {
|
|
33
|
+
const onRevealedChange = jest.fn()
|
|
34
|
+
const { container, rerender } = render(
|
|
35
|
+
<PasswordInput defaultValue="secret" revealed={false} onRevealedChange={onRevealedChange} />,
|
|
36
|
+
)
|
|
37
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
38
|
+
expect(input.type).toBe('password')
|
|
39
|
+
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: 'Show password' }))
|
|
41
|
+
expect(onRevealedChange).toHaveBeenCalledWith(true)
|
|
42
|
+
expect(input.type).toBe('password')
|
|
43
|
+
|
|
44
|
+
rerender(<PasswordInput defaultValue="secret" revealed onRevealedChange={onRevealedChange} />)
|
|
45
|
+
expect(input.type).toBe('text')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('hides the reveal toggle when revealable is false', () => {
|
|
49
|
+
render(<PasswordInput defaultValue="secret" revealable={false} />)
|
|
50
|
+
expect(screen.queryByRole('button', { name: /show password|hide password/i })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('hides the leading lock icon when showLockIcon is false', () => {
|
|
54
|
+
const { container } = render(<PasswordInput showLockIcon={false} defaultValue="secret" />)
|
|
55
|
+
expect(container.querySelector('svg.lucide-lock')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('forwards consumer-supplied autoComplete to the input element', () => {
|
|
59
|
+
const { container } = render(
|
|
60
|
+
<PasswordInput defaultValue="secret" autoComplete="new-password" />,
|
|
61
|
+
)
|
|
62
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
63
|
+
expect(input).toHaveAttribute('autocomplete', 'new-password')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not set autoComplete when the consumer omits it (lets browser heuristics decide)', () => {
|
|
67
|
+
const { container } = render(<PasswordInput defaultValue="secret" />)
|
|
68
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
69
|
+
expect(input.getAttribute('autocomplete')).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('forwards name and id to the input element', () => {
|
|
73
|
+
const { container } = render(<PasswordInput id="signup-password" name="password" />)
|
|
74
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
75
|
+
expect(input.id).toBe('signup-password')
|
|
76
|
+
expect(input.name).toBe('password')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('disables both the input and the reveal toggle when disabled', () => {
|
|
80
|
+
const { container } = render(<PasswordInput defaultValue="secret" disabled />)
|
|
81
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
82
|
+
const toggle = screen.getByRole('button', { name: 'Show password' }) as HTMLButtonElement
|
|
83
|
+
expect(input.disabled).toBe(true)
|
|
84
|
+
expect(toggle.disabled).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('forwards refs to the underlying input element', () => {
|
|
88
|
+
const ref = React.createRef<HTMLInputElement>()
|
|
89
|
+
render(<PasswordInput ref={ref} defaultValue="secret" />)
|
|
90
|
+
expect(ref.current).not.toBeNull()
|
|
91
|
+
expect(ref.current?.tagName).toBe('INPUT')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
4
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import * as React from 'react'
|
|
8
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
import { SearchInput } from '../search-input'
|
|
10
|
+
|
|
11
|
+
function Harness(initial: string, props: Partial<React.ComponentProps<typeof SearchInput>> = {}) {
|
|
12
|
+
const { onChange: consumerOnChange, ...rest } = props
|
|
13
|
+
function Wrapped() {
|
|
14
|
+
const [value, setValue] = React.useState(initial)
|
|
15
|
+
return (
|
|
16
|
+
<SearchInput
|
|
17
|
+
{...rest}
|
|
18
|
+
value={value}
|
|
19
|
+
onChange={(next) => {
|
|
20
|
+
setValue(next)
|
|
21
|
+
consumerOnChange?.(next)
|
|
22
|
+
}}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return render(<Wrapped />)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('SearchInput primitive', () => {
|
|
30
|
+
it('renders an input with type="search"', () => {
|
|
31
|
+
const { container } = Harness('')
|
|
32
|
+
const input = container.querySelector('input') as HTMLInputElement
|
|
33
|
+
expect(input).not.toBeNull()
|
|
34
|
+
expect(input.type).toBe('search')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('exposes the searchbox ARIA role (matches Playwright getByRole expectations)', () => {
|
|
38
|
+
Harness('')
|
|
39
|
+
expect(screen.getByRole('searchbox')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('uses the translated placeholder fallback', () => {
|
|
43
|
+
Harness('')
|
|
44
|
+
expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('honors a consumer-supplied placeholder over the i18n fallback', () => {
|
|
48
|
+
Harness('', { placeholder: 'Find a customer' })
|
|
49
|
+
expect(screen.getByPlaceholderText('Find a customer')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not render the clear button when value is empty', () => {
|
|
53
|
+
Harness('')
|
|
54
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the clear button when value is non-empty', () => {
|
|
58
|
+
Harness('jan')
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('clicking the clear button calls onChange("") when no onClear is supplied', () => {
|
|
63
|
+
const onChange = jest.fn()
|
|
64
|
+
Harness('jan', { onChange })
|
|
65
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith('')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('clicking the clear button calls onClear instead of onChange when onClear is supplied', () => {
|
|
70
|
+
const onChange = jest.fn()
|
|
71
|
+
const onClear = jest.fn()
|
|
72
|
+
Harness('jan', { onChange, onClear })
|
|
73
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
74
|
+
expect(onClear).toHaveBeenCalledTimes(1)
|
|
75
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('hides the clear button when clearable=false even with non-empty value', () => {
|
|
79
|
+
Harness('jan', { clearable: false })
|
|
80
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('hides the clear button when disabled', () => {
|
|
84
|
+
Harness('jan', { disabled: true })
|
|
85
|
+
expect(screen.queryByRole('button', { name: /clear search/i })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('forwards typing to onChange', () => {
|
|
89
|
+
const onChange = jest.fn()
|
|
90
|
+
Harness('', { onChange })
|
|
91
|
+
const input = screen.getByRole('searchbox') as HTMLInputElement
|
|
92
|
+
fireEvent.change(input, { target: { value: 'kowalski' } })
|
|
93
|
+
expect(onChange).toHaveBeenCalledWith('kowalski')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
2
|
+
useT: () => (_key: string, fallback: string) => fallback,
|
|
3
|
+
}))
|
|
4
|
+
|
|
1
5
|
import * as React from 'react'
|
|
2
6
|
import * as fs from 'fs'
|
|
3
7
|
import * as path from 'path'
|
|
@@ -14,6 +18,7 @@ const Z_INDEX_BY_TOKEN: Record<string, number> = {
|
|
|
14
18
|
'z-modal': 40,
|
|
15
19
|
'z-popover': 45,
|
|
16
20
|
'z-toast': 50,
|
|
21
|
+
'z-modal-elevated': 55,
|
|
17
22
|
'z-tooltip': 60,
|
|
18
23
|
'z-banner': 70,
|
|
19
24
|
'z-top': 100,
|
|
@@ -129,4 +134,25 @@ describe('Issue #1836: portaled overlay primitives sit above modals (z-popover >
|
|
|
129
134
|
expect(css).toContain('z-index: var(--z-index-popover);')
|
|
130
135
|
}
|
|
131
136
|
})
|
|
137
|
+
|
|
138
|
+
it('globals.css defines z-modal-elevated (55) above z-popover so dialogs opened from inside popovers are visible', () => {
|
|
139
|
+
const repoRoot = path.resolve(__dirname, '../../../../..')
|
|
140
|
+
const cssPaths = [
|
|
141
|
+
path.join(repoRoot, 'apps/mercato/src/app/globals.css'),
|
|
142
|
+
path.join(repoRoot, 'packages/create-app/template/src/app/globals.css'),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
for (const cssPath of cssPaths) {
|
|
146
|
+
const css = fs.readFileSync(cssPath, 'utf8')
|
|
147
|
+
|
|
148
|
+
expect(css).toContain('@utility z-modal-elevated')
|
|
149
|
+
expect(css).toMatch(/z-index:\s*var\(--z-index-modal-elevated(?:,\s*\d+)?\);/)
|
|
150
|
+
|
|
151
|
+
const elevatedMatch = css.match(/--z-index-modal-elevated:\s*(\d+);/)
|
|
152
|
+
const popoverMatch = css.match(/--z-index-popover:\s*(\d+);/)
|
|
153
|
+
expect(elevatedMatch).not.toBeNull()
|
|
154
|
+
expect(popoverMatch).not.toBeNull()
|
|
155
|
+
expect(Number(elevatedMatch![1])).toBeGreaterThan(Number(popoverMatch![1]))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
132
158
|
})
|