@startsimpli/ui 0.4.110 → 0.4.111
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/package.json +7 -5
- package/src/components/facet-tree/FacetTree.tsx +250 -0
- package/src/components/facet-tree/__tests__/facet-tree.test.tsx +228 -0
- package/src/components/facet-tree/index.ts +26 -0
- package/src/components/facet-tree/path.ts +55 -0
- package/src/components/facet-tree/types.ts +89 -0
- package/src/components/facet-tree/useFacetLevels.ts +197 -0
- package/src/components/index.ts +46 -0
- package/src/components/scope/ScopeSwitcher.tsx +548 -0
- package/src/components/scope/__tests__/scope-client.test.ts +131 -0
- package/src/components/scope/__tests__/scope-switcher.test.tsx +338 -0
- package/src/components/scope/__tests__/use-scope-param.test.tsx +265 -0
- package/src/components/scope/client.ts +145 -0
- package/src/components/scope/index.ts +34 -0
- package/src/components/scope/types.ts +90 -0
- package/src/components/scope/useScopeParam.ts +200 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ScopeSwitcher/> (bd startsim-6fceh) — pick a client or a project,
|
|
3
|
+
* hierarchically, and stay there.
|
|
4
|
+
*
|
|
5
|
+
* The engine underneath is pinned in `../../facet-tree/__tests__`; these pin the
|
|
6
|
+
* switcher's own contract: what the trigger says, that a closed switcher costs
|
|
7
|
+
* nothing, that "All scopes" CLEARS the bound, that a selection is the server's
|
|
8
|
+
* own path verbatim, and that searching is a second server read which admits it
|
|
9
|
+
* when it cannot happen.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react'
|
|
12
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
import userEvent from '@testing-library/user-event'
|
|
14
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
15
|
+
|
|
16
|
+
import { ScopeSwitcher, ScopeSwitcherControlled } from '../ScopeSwitcher'
|
|
17
|
+
import type { ScopeClient, ScopeMatch, ScopeNode } from '../types'
|
|
18
|
+
import type { FacetLevelQuery, Paginated } from '../../facet-tree/types'
|
|
19
|
+
|
|
20
|
+
const mockedSearchParams = useSearchParams as unknown as jest.Mock
|
|
21
|
+
const mockedRouter = useRouter as unknown as jest.Mock
|
|
22
|
+
const mockedPathname = usePathname as unknown as jest.Mock
|
|
23
|
+
|
|
24
|
+
function node(path: string, count: number, hasChildren = false): ScopeNode {
|
|
25
|
+
const segments = path.split('/').filter(Boolean)
|
|
26
|
+
return { path, name: segments[segments.length - 1] ?? path, count, hasChildren }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function page<T>(results: T[], count = results.length): Paginated<T> {
|
|
30
|
+
return { count, next: null, previous: null, results }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LEVELS: Record<string, Paginated<ScopeNode>> = {
|
|
34
|
+
'/': page([node('/Clients', 1204, true), node('/Internal', 12)]),
|
|
35
|
+
'/Clients': page([node('/Clients/Acme', 900, true), node('/Clients/Globex', 300)]),
|
|
36
|
+
'/Clients/Acme': page([node('/Clients/Acme/GCC Weekly', 41)]),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeClient(overrides: Partial<ScopeClient> = {}): ScopeClient & {
|
|
40
|
+
listScopeChildren: jest.Mock
|
|
41
|
+
} {
|
|
42
|
+
const listScopeChildren = jest.fn(
|
|
43
|
+
async (query: FacetLevelQuery) => LEVELS[query.under] ?? page<ScopeNode>([]),
|
|
44
|
+
)
|
|
45
|
+
return { listScopeChildren, ...overrides } as ScopeClient & { listScopeChildren: jest.Mock }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function open(name = /^Scope:/) {
|
|
49
|
+
return screen.getByRole('button', { name })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('ScopeSwitcher — the trigger', () => {
|
|
53
|
+
it('says "All scopes" when nothing is in scope', () => {
|
|
54
|
+
render(<ScopeSwitcherControlled client={makeClient()} value={null} onChange={jest.fn()} />)
|
|
55
|
+
expect(open()).toHaveTextContent('All scopes')
|
|
56
|
+
expect(screen.getByRole('button', { name: 'Scope: All scopes' })).toBeTruthy()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('shows the leaf name, with its ancestors underneath to tell two "Weekly"s apart', () => {
|
|
60
|
+
render(
|
|
61
|
+
<ScopeSwitcherControlled
|
|
62
|
+
client={makeClient()}
|
|
63
|
+
value="/Clients/Acme/GCC Weekly"
|
|
64
|
+
onChange={jest.fn()}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
const trigger = open()
|
|
68
|
+
expect(trigger).toHaveTextContent('GCC Weekly')
|
|
69
|
+
expect(trigger).toHaveTextContent('Clients / Acme')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('names itself with the FULL path, since the visible label is only a leaf', () => {
|
|
73
|
+
render(
|
|
74
|
+
<ScopeSwitcherControlled client={makeClient()} value="/Clients/Acme" onChange={jest.fn()} />,
|
|
75
|
+
)
|
|
76
|
+
expect(screen.getByRole('button', { name: 'Scope: /Clients/Acme' })).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('takes the control’s own noun from labels, for an app that says "Client"', () => {
|
|
80
|
+
render(
|
|
81
|
+
<ScopeSwitcherControlled
|
|
82
|
+
client={makeClient()}
|
|
83
|
+
value={null}
|
|
84
|
+
onChange={jest.fn()}
|
|
85
|
+
labels={{ control: 'Client', root: 'All clients' }}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
expect(screen.getByRole('button', { name: 'Client: All clients' })).toBeTruthy()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('costs no round trip until it is opened', () => {
|
|
92
|
+
const client = makeClient()
|
|
93
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
94
|
+
expect(client.listScopeChildren).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('ScopeSwitcher — browsing', () => {
|
|
99
|
+
it('loads the root level on first open, with counts', async () => {
|
|
100
|
+
const user = userEvent.setup()
|
|
101
|
+
const client = makeClient()
|
|
102
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
103
|
+
|
|
104
|
+
await user.click(open())
|
|
105
|
+
|
|
106
|
+
expect(await screen.findByRole('button', { name: /^Clients, 1,204 records inside$/ })).toBeTruthy()
|
|
107
|
+
expect(client.listScopeChildren).toHaveBeenCalledTimes(1)
|
|
108
|
+
expect(client.listScopeChildren).toHaveBeenCalledWith(
|
|
109
|
+
expect.objectContaining({ under: '/', page: 1 }),
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('CLEARS the bound for "All scopes" — it does not select a root path', async () => {
|
|
114
|
+
// A synthetic `/` would narrow to rows that HAVE a scope, and every record is
|
|
115
|
+
// scope-less until somebody assigns it. Narrowing here would empty the screen
|
|
116
|
+
// of the person who asked to see everything.
|
|
117
|
+
const user = userEvent.setup()
|
|
118
|
+
const onChange = jest.fn()
|
|
119
|
+
render(
|
|
120
|
+
<ScopeSwitcherControlled client={makeClient()} value="/Clients" onChange={onChange} />,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
await user.click(open())
|
|
124
|
+
await user.click(await screen.findByRole('button', { name: 'All scopes' }))
|
|
125
|
+
|
|
126
|
+
expect(onChange).toHaveBeenCalledWith(null)
|
|
127
|
+
expect(onChange).not.toHaveBeenCalledWith('/')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('hands back the server’s own path, verbatim, and closes', async () => {
|
|
131
|
+
const user = userEvent.setup()
|
|
132
|
+
const onChange = jest.fn()
|
|
133
|
+
render(<ScopeSwitcherControlled client={makeClient()} value={null} onChange={onChange} />)
|
|
134
|
+
|
|
135
|
+
await user.click(open())
|
|
136
|
+
await user.click(await screen.findByRole('button', { name: 'Expand Clients' }))
|
|
137
|
+
await user.click(await screen.findByRole('button', { name: /^Acme, 900 records inside$/ }))
|
|
138
|
+
|
|
139
|
+
expect(onChange).toHaveBeenCalledWith('/Clients/Acme')
|
|
140
|
+
await waitFor(() =>
|
|
141
|
+
expect(screen.queryByRole('button', { name: 'All scopes' })).toBeNull(),
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('marks the scope in play as current', async () => {
|
|
146
|
+
const user = userEvent.setup()
|
|
147
|
+
render(
|
|
148
|
+
<ScopeSwitcherControlled client={makeClient()} value="/Internal" onChange={jest.fn()} />,
|
|
149
|
+
)
|
|
150
|
+
await user.click(open())
|
|
151
|
+
const row = await screen.findByRole('button', { name: /^Internal, 12 records inside$/ })
|
|
152
|
+
expect(row).toHaveAttribute('aria-current', 'true')
|
|
153
|
+
expect(screen.getByRole('button', { name: 'All scopes' })).not.toHaveAttribute('aria-current')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('says so when the tenant has declared scopes but has none yet', async () => {
|
|
157
|
+
const user = userEvent.setup()
|
|
158
|
+
const client = makeClient()
|
|
159
|
+
client.listScopeChildren.mockResolvedValue(page<ScopeNode>([]))
|
|
160
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
161
|
+
|
|
162
|
+
await user.click(open())
|
|
163
|
+
expect(await screen.findByText('No scopes yet')).toBeTruthy()
|
|
164
|
+
// "All scopes" is still there — an empty tree does not take away the way out.
|
|
165
|
+
expect(screen.getByRole('button', { name: 'All scopes' })).toBeTruthy()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('tells the app once when the deployment has no facet route at all', async () => {
|
|
169
|
+
const user = userEvent.setup()
|
|
170
|
+
const onUnavailable = jest.fn()
|
|
171
|
+
const client = makeClient()
|
|
172
|
+
client.listScopeChildren.mockRejectedValue(Object.assign(new Error('no'), { status: 404 }))
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
<ScopeSwitcherControlled
|
|
176
|
+
client={client}
|
|
177
|
+
value={null}
|
|
178
|
+
onChange={jest.fn()}
|
|
179
|
+
onUnavailable={onUnavailable}
|
|
180
|
+
/>,
|
|
181
|
+
)
|
|
182
|
+
await user.click(open())
|
|
183
|
+
await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(1))
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('ScopeSwitcher — searching', () => {
|
|
188
|
+
const matches: ScopeMatch[] = [
|
|
189
|
+
{ path: '/Clients/Acme/GCC Weekly', name: 'GCC Weekly' },
|
|
190
|
+
{ path: '/Internal/Weekly', name: 'Weekly' },
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
it('asks the server, and shows each match with its whole path', async () => {
|
|
194
|
+
const user = userEvent.setup()
|
|
195
|
+
const searchScopes = jest.fn(async () => page(matches))
|
|
196
|
+
const client = makeClient({ searchScopes })
|
|
197
|
+
|
|
198
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
199
|
+
await user.click(open())
|
|
200
|
+
await user.type(screen.getByLabelText('Search scopes'), 'weekly')
|
|
201
|
+
|
|
202
|
+
await waitFor(() =>
|
|
203
|
+
expect(searchScopes).toHaveBeenCalledWith(expect.objectContaining({ search: 'weekly' })),
|
|
204
|
+
)
|
|
205
|
+
expect(
|
|
206
|
+
await screen.findByRole('button', { name: '/Clients/Acme/GCC Weekly' }),
|
|
207
|
+
).toBeTruthy()
|
|
208
|
+
// A flat result list is exactly where two identically-named scopes meet, so
|
|
209
|
+
// the path is on screen and not only in the accessible name.
|
|
210
|
+
expect(screen.getByTitle('/Internal/Weekly')).toBeTruthy()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('selects out of the results without ever expanding the tree', async () => {
|
|
214
|
+
const user = userEvent.setup()
|
|
215
|
+
const onChange = jest.fn()
|
|
216
|
+
const client = makeClient({ searchScopes: jest.fn(async () => page(matches)) })
|
|
217
|
+
|
|
218
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={onChange} />)
|
|
219
|
+
await user.click(open())
|
|
220
|
+
await user.type(screen.getByLabelText('Search scopes'), 'weekly')
|
|
221
|
+
await user.click(await screen.findByRole('button', { name: '/Internal/Weekly' }))
|
|
222
|
+
|
|
223
|
+
expect(onChange).toHaveBeenCalledWith('/Internal/Weekly')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('says when it matched nothing', async () => {
|
|
227
|
+
const user = userEvent.setup()
|
|
228
|
+
const client = makeClient({ searchScopes: jest.fn(async () => page<ScopeMatch>([])) })
|
|
229
|
+
|
|
230
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
231
|
+
await user.click(open())
|
|
232
|
+
await user.type(screen.getByLabelText('Search scopes'), 'zzz')
|
|
233
|
+
|
|
234
|
+
expect(await screen.findByText('No matching scopes')).toBeTruthy()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('ADMITS ITS BOUND when the client cannot search the whole type', async () => {
|
|
238
|
+
// Filtering the levels already loaded is an honest thing to offer and a
|
|
239
|
+
// dishonest thing to offer silently: it cannot see a subtree nobody has
|
|
240
|
+
// expanded, so the control has to say what it is answering about.
|
|
241
|
+
const user = userEvent.setup()
|
|
242
|
+
const client = makeClient() // no searchScopes at all
|
|
243
|
+
|
|
244
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
245
|
+
await user.click(open())
|
|
246
|
+
await screen.findByRole('button', { name: /^Clients, 1,204 records inside$/ })
|
|
247
|
+
await user.type(screen.getByLabelText('Search scopes'), 'intern')
|
|
248
|
+
|
|
249
|
+
expect(await screen.findByText('Searching the scopes loaded so far.')).toBeTruthy()
|
|
250
|
+
// A locally-filtered row came out of the tree, so it still carries its count.
|
|
251
|
+
expect(screen.getByRole('button', { name: '/Internal, 12 records inside' })).toBeTruthy()
|
|
252
|
+
expect(screen.queryByTitle('/Clients')).toBeNull()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('falls back to the same local filter when the server refuses the search', async () => {
|
|
256
|
+
const user = userEvent.setup()
|
|
257
|
+
const searchScopes = jest.fn(async () => {
|
|
258
|
+
throw Object.assign(new Error('nope'), { status: 400 })
|
|
259
|
+
})
|
|
260
|
+
const client = makeClient({ searchScopes })
|
|
261
|
+
|
|
262
|
+
render(<ScopeSwitcherControlled client={client} value={null} onChange={jest.fn()} />)
|
|
263
|
+
await user.click(open())
|
|
264
|
+
await screen.findByRole('button', { name: /^Clients, 1,204 records inside$/ })
|
|
265
|
+
await user.type(screen.getByLabelText('Search scopes'), 'intern')
|
|
266
|
+
|
|
267
|
+
expect(await screen.findByText('Searching the scopes loaded so far.')).toBeTruthy()
|
|
268
|
+
expect(screen.getByRole('button', { name: '/Internal, 12 records inside' })).toBeTruthy()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('reports a real search failure instead of quietly narrowing', async () => {
|
|
272
|
+
const user = userEvent.setup()
|
|
273
|
+
const searchScopes = jest.fn(async () => {
|
|
274
|
+
throw Object.assign(new Error('Search is down'), { status: 500 })
|
|
275
|
+
})
|
|
276
|
+
render(
|
|
277
|
+
<ScopeSwitcherControlled
|
|
278
|
+
client={makeClient({ searchScopes })}
|
|
279
|
+
value={null}
|
|
280
|
+
onChange={jest.fn()}
|
|
281
|
+
/>,
|
|
282
|
+
)
|
|
283
|
+
await user.click(open())
|
|
284
|
+
await user.type(screen.getByLabelText('Search scopes'), 'weekly')
|
|
285
|
+
|
|
286
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Search is down')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('ScopeSwitcher — bound to the URL', () => {
|
|
291
|
+
const replace = jest.fn()
|
|
292
|
+
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
replace.mockClear()
|
|
295
|
+
mockedRouter.mockReturnValue({ replace, push: jest.fn() })
|
|
296
|
+
mockedPathname.mockReturnValue('/drafts')
|
|
297
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams())
|
|
298
|
+
window.sessionStorage.clear()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('takes its selection from the query string, with no value prop to go stale', async () => {
|
|
302
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=%2FClients%2FAcme'))
|
|
303
|
+
render(<ScopeSwitcher client={makeClient()} />)
|
|
304
|
+
expect(await screen.findByRole('button', { name: 'Scope: /Clients/Acme' })).toBeTruthy()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('follows the URL when a soft navigation changes it under a layout', async () => {
|
|
308
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=%2FClients%2FAcme'))
|
|
309
|
+
const client = makeClient()
|
|
310
|
+
const { rerender } = render(<ScopeSwitcher client={client} remember={false} />)
|
|
311
|
+
expect(await screen.findByRole('button', { name: 'Scope: /Clients/Acme' })).toBeTruthy()
|
|
312
|
+
|
|
313
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=%2FInternal'))
|
|
314
|
+
rerender(<ScopeSwitcher client={client} remember={false} />)
|
|
315
|
+
expect(await screen.findByRole('button', { name: 'Scope: /Internal' })).toBeTruthy()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('writes the chosen scope into the URL, so the view is a link', async () => {
|
|
319
|
+
const user = userEvent.setup()
|
|
320
|
+
render(<ScopeSwitcher client={makeClient()} remember={false} />)
|
|
321
|
+
|
|
322
|
+
await user.click(await screen.findByRole('button', { name: /^Scope:/ }))
|
|
323
|
+
await user.click(await screen.findByRole('button', { name: /^Internal, 12 records inside$/ }))
|
|
324
|
+
|
|
325
|
+
expect(replace).toHaveBeenCalledWith('/drafts?scope=%2FInternal', { scroll: false })
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('writes the reserved "all" value, so "everything" is a link too', async () => {
|
|
329
|
+
const user = userEvent.setup()
|
|
330
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams('scope=%2FInternal'))
|
|
331
|
+
render(<ScopeSwitcher client={makeClient()} remember={false} />)
|
|
332
|
+
|
|
333
|
+
await user.click(await screen.findByRole('button', { name: /^Scope:/ }))
|
|
334
|
+
await user.click(await screen.findByRole('button', { name: 'All scopes' }))
|
|
335
|
+
|
|
336
|
+
expect(replace).toHaveBeenCalledWith('/drafts?scope=all', { scroll: false })
|
|
337
|
+
})
|
|
338
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL binding (bd startsim-6fceh).
|
|
3
|
+
*
|
|
4
|
+
* THE TEST THAT MATTERS MOST is "follows the URL on a soft navigation". A Next
|
|
5
|
+
* layout is not re-rendered on client-side navigation, so anything that reads
|
|
6
|
+
* the query ONCE — into `useState`, or in a server layout via a middleware
|
|
7
|
+
* header — renders the previous page's scope under the next page's data. That is
|
|
8
|
+
* `feedback_no_searchparam_state_in_layouts`, and it is a real outage this repo
|
|
9
|
+
* has already had. The pin is that a re-render with different params changes the
|
|
10
|
+
* answer, i.e. the hook derives rather than caches.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react'
|
|
13
|
+
import { render, screen, act } from '@testing-library/react'
|
|
14
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
15
|
+
|
|
16
|
+
import { useScopeParam } from '../useScopeParam'
|
|
17
|
+
|
|
18
|
+
const mockedSearchParams = useSearchParams as unknown as jest.Mock
|
|
19
|
+
const mockedRouter = useRouter as unknown as jest.Mock
|
|
20
|
+
const mockedPathname = usePathname as unknown as jest.Mock
|
|
21
|
+
|
|
22
|
+
const replace = jest.fn()
|
|
23
|
+
const push = jest.fn()
|
|
24
|
+
|
|
25
|
+
function atUrl(query: string, pathname = '/drafts') {
|
|
26
|
+
mockedSearchParams.mockReturnValue(new URLSearchParams(query))
|
|
27
|
+
mockedPathname.mockReturnValue(pathname)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let latest: ReturnType<typeof useScopeParam>
|
|
31
|
+
|
|
32
|
+
function Probe(options: Parameters<typeof useScopeParam>[0] = {}) {
|
|
33
|
+
latest = useScopeParam(options)
|
|
34
|
+
return <span data-testid="scope">{latest.scope ?? 'none'}</span>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
replace.mockClear()
|
|
39
|
+
push.mockClear()
|
|
40
|
+
mockedRouter.mockReturnValue({ replace, push })
|
|
41
|
+
window.sessionStorage.clear()
|
|
42
|
+
atUrl('')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('useScopeParam — reading', () => {
|
|
46
|
+
it('reads the scope out of the query string', () => {
|
|
47
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
48
|
+
render(<Probe />)
|
|
49
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('/Clients/Acme')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('treats an absent or blank parameter as no bound at all', () => {
|
|
53
|
+
atUrl('')
|
|
54
|
+
const { rerender } = render(<Probe remember={false} />)
|
|
55
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('none')
|
|
56
|
+
|
|
57
|
+
atUrl('scope=')
|
|
58
|
+
rerender(<Probe remember={false} />)
|
|
59
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('none')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('FOLLOWS THE URL ON A SOFT NAVIGATION rather than caching the first read', () => {
|
|
63
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
64
|
+
const { rerender } = render(<Probe remember={false} />)
|
|
65
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('/Clients/Acme')
|
|
66
|
+
|
|
67
|
+
// The app router pushed new search params under the same mounted component,
|
|
68
|
+
// which is exactly what a soft nav does to a control living in a layout.
|
|
69
|
+
atUrl('scope=%2FClients%2FGlobex')
|
|
70
|
+
rerender(<Probe remember={false} />)
|
|
71
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('/Clients/Globex')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('useScopeParam — writing', () => {
|
|
76
|
+
it('writes the full path and keeps every other parameter', () => {
|
|
77
|
+
atUrl('tab=open&q=acme')
|
|
78
|
+
render(<Probe remember={false} />)
|
|
79
|
+
|
|
80
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
81
|
+
|
|
82
|
+
expect(replace).toHaveBeenCalledWith('/drafts?tab=open&q=acme&scope=%2FClients%2FAcme', {
|
|
83
|
+
scroll: false,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('writes the reserved "all" value rather than selecting a root path', () => {
|
|
88
|
+
// Selecting a synthetic `/` would narrow to rows that HAVE a path, and the
|
|
89
|
+
// facet read skips rows whose attribute is null — so every unscoped record
|
|
90
|
+
// would vanish from the screen of whoever picked "all". And DELETING the
|
|
91
|
+
// parameter would make "I chose everything" indistinguishable from "nobody
|
|
92
|
+
// has chosen", which is the difference `remember` turns on.
|
|
93
|
+
atUrl('scope=%2FClients%2FAcme&tab=open')
|
|
94
|
+
render(<Probe remember={false} />)
|
|
95
|
+
|
|
96
|
+
act(() => latest.setScope(null))
|
|
97
|
+
|
|
98
|
+
// `set` keeps an existing key where it already was, hence scope first here.
|
|
99
|
+
expect(replace).toHaveBeenCalledWith('/drafts?scope=all&tab=open', { scroll: false })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('reads the reserved value back out as no bound at all', () => {
|
|
103
|
+
atUrl('scope=all')
|
|
104
|
+
render(<Probe remember={false} />)
|
|
105
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('none')
|
|
106
|
+
expect(latest.asserted).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('tells an assertion of "everything" apart from a URL that says nothing', () => {
|
|
110
|
+
atUrl('')
|
|
111
|
+
const { rerender } = render(<Probe remember={false} />)
|
|
112
|
+
expect(latest.scope).toBeNull()
|
|
113
|
+
expect(latest.asserted).toBe(false)
|
|
114
|
+
|
|
115
|
+
atUrl('scope=all')
|
|
116
|
+
rerender(<Probe remember={false} />)
|
|
117
|
+
expect(latest.scope).toBeNull()
|
|
118
|
+
expect(latest.asserted).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('can push instead of replace, for an app that wants scope in history', () => {
|
|
122
|
+
atUrl('')
|
|
123
|
+
render(<Probe remember={false} history="push" />)
|
|
124
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
125
|
+
expect(push).toHaveBeenCalledWith('/drafts?scope=%2FClients%2FAcme', { scroll: false })
|
|
126
|
+
expect(replace).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('honours a custom parameter name', () => {
|
|
130
|
+
atUrl('client=%2FAcme')
|
|
131
|
+
render(<Probe param="client" remember={false} />)
|
|
132
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('/Acme')
|
|
133
|
+
act(() => latest.setScope('/Globex'))
|
|
134
|
+
expect(replace).toHaveBeenCalledWith('/drafts?client=%2FGlobex', { scroll: false })
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('useScopeParam — withScope', () => {
|
|
139
|
+
it('carries "all" onto a link too, so everything keeps meaning everything', () => {
|
|
140
|
+
atUrl('scope=all')
|
|
141
|
+
render(<Probe remember={false} />)
|
|
142
|
+
expect(latest.withScope('/topics')).toBe('/topics?scope=all')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('carries the scope onto a link, because <Link> drops the query string', () => {
|
|
146
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
147
|
+
render(<Probe remember={false} />)
|
|
148
|
+
expect(latest.withScope('/topics')).toBe('/topics?scope=%2FClients%2FAcme')
|
|
149
|
+
expect(latest.withScope('/topics?tab=open')).toBe('/topics?tab=open&scope=%2FClients%2FAcme')
|
|
150
|
+
expect(latest.withScope('/topics#latest')).toBe('/topics?scope=%2FClients%2FAcme#latest')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('leaves a link that already names a scope alone', () => {
|
|
154
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
155
|
+
render(<Probe remember={false} />)
|
|
156
|
+
expect(latest.withScope('/topics?scope=%2FInternal')).toBe('/topics?scope=%2FInternal')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('is a no-op when the URL asserts nothing', () => {
|
|
160
|
+
atUrl('')
|
|
161
|
+
render(<Probe remember={false} />)
|
|
162
|
+
expect(latest.withScope('/topics')).toBe('/topics')
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('useScopeParam — remembering, the safety net', () => {
|
|
167
|
+
it('restores the last choice onto a route that carries no scope', () => {
|
|
168
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
169
|
+
const { unmount } = render(<Probe />)
|
|
170
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
171
|
+
replace.mockClear()
|
|
172
|
+
unmount()
|
|
173
|
+
|
|
174
|
+
// A <Link href="/topics"> that dropped the query.
|
|
175
|
+
atUrl('', '/topics')
|
|
176
|
+
render(<Probe />)
|
|
177
|
+
expect(replace).toHaveBeenCalledWith('/topics?scope=%2FClients%2FAcme', { scroll: false })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('never overrides a scope the URL already names — a shared link wins', () => {
|
|
181
|
+
atUrl('')
|
|
182
|
+
const { unmount } = render(<Probe />)
|
|
183
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
184
|
+
replace.mockClear()
|
|
185
|
+
unmount()
|
|
186
|
+
|
|
187
|
+
atUrl('scope=%2FInternal', '/topics')
|
|
188
|
+
render(<Probe />)
|
|
189
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('/Internal')
|
|
190
|
+
expect(replace).not.toHaveBeenCalled()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('remembers "all scopes" as a CHOICE, and restores it as the assertion', () => {
|
|
194
|
+
atUrl('scope=%2FClients%2FAcme')
|
|
195
|
+
const { unmount } = render(<Probe />)
|
|
196
|
+
act(() => latest.setScope(null))
|
|
197
|
+
replace.mockClear()
|
|
198
|
+
unmount()
|
|
199
|
+
|
|
200
|
+
atUrl('', '/topics')
|
|
201
|
+
render(<Probe />)
|
|
202
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('none')
|
|
203
|
+
expect(replace).toHaveBeenCalledWith('/topics?scope=all', { scroll: false })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('LEAVES A SHARED "everything" LINK ALONE, even for a tab that is scoped', () => {
|
|
207
|
+
// The case this whole reserved value exists for. Somebody sends "look at all
|
|
208
|
+
// of it"; without an assertion the reader's own remembered scope would be
|
|
209
|
+
// written over the top of it and they would read one client's rows under a
|
|
210
|
+
// link that named none.
|
|
211
|
+
atUrl('')
|
|
212
|
+
const { unmount } = render(<Probe />)
|
|
213
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
214
|
+
replace.mockClear()
|
|
215
|
+
unmount()
|
|
216
|
+
|
|
217
|
+
atUrl('scope=all', '/topics')
|
|
218
|
+
render(<Probe />)
|
|
219
|
+
expect(screen.getByTestId('scope')).toHaveTextContent('none')
|
|
220
|
+
expect(replace).not.toHaveBeenCalled()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('attempts the restore at most once, so a declined replace is not a loop', () => {
|
|
224
|
+
atUrl('')
|
|
225
|
+
const { unmount } = render(<Probe />)
|
|
226
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
227
|
+
replace.mockClear()
|
|
228
|
+
unmount()
|
|
229
|
+
|
|
230
|
+
// The host never honours the replace, so the params never change. A restore
|
|
231
|
+
// keyed only on "the URL still has no scope" would fire on every render.
|
|
232
|
+
atUrl('', '/topics')
|
|
233
|
+
const { rerender } = render(<Probe />)
|
|
234
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
235
|
+
rerender(<Probe />)
|
|
236
|
+
rerender(<Probe />)
|
|
237
|
+
expect(replace).toHaveBeenCalledTimes(1)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('does nothing at all when remembering is switched off', () => {
|
|
241
|
+
atUrl('')
|
|
242
|
+
const { unmount } = render(<Probe remember={false} />)
|
|
243
|
+
act(() => latest.setScope('/Clients/Acme'))
|
|
244
|
+
replace.mockClear()
|
|
245
|
+
unmount()
|
|
246
|
+
|
|
247
|
+
atUrl('', '/topics')
|
|
248
|
+
render(<Probe remember={false} />)
|
|
249
|
+
expect(replace).not.toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('survives sessionStorage throwing outright', () => {
|
|
253
|
+
const spy = jest.spyOn(window.sessionStorage.__proto__, 'setItem').mockImplementation(() => {
|
|
254
|
+
throw new Error('blocked')
|
|
255
|
+
})
|
|
256
|
+
try {
|
|
257
|
+
atUrl('')
|
|
258
|
+
render(<Probe />)
|
|
259
|
+
expect(() => act(() => latest.setScope('/Clients/Acme'))).not.toThrow()
|
|
260
|
+
expect(replace).toHaveBeenCalled()
|
|
261
|
+
} finally {
|
|
262
|
+
spy.mockRestore()
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
})
|