Skip to content

Commit befc3b7

Browse files
committed
Enforce limited view segment on dashboard
1 parent 32652d5 commit befc3b7

File tree

8 files changed

+131
-63
lines changed

8 files changed

+131
-63
lines changed

assets/js/dashboard.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { createAppRouter } from './dashboard/router'
77
import ErrorBoundary from './dashboard/error/error-boundary'
88
import * as api from './dashboard/api'
99
import * as timer from './dashboard/util/realtime-update-timer'
10-
import { redirectForLegacyParams } from './dashboard/util/url-search-params'
10+
import { maybeDoFERedirect } from './dashboard/util/url-search-params'
1111
import SiteContextProvider, {
1212
parseSiteFromDataset
1313
} from './dashboard/site-context'
@@ -19,6 +19,7 @@ import {
1919
SomethingWentWrongMessage
2020
} from './dashboard/error/something-went-wrong'
2121
import {
22+
getLimitedToSegment,
2223
parseLimitedToSegmentId,
2324
parsePreloadedSegments,
2425
SegmentsContextProvider
@@ -40,8 +41,15 @@ if (container && container.dataset) {
4041
api.setSharedLinkAuth(sharedLinkAuth)
4142
}
4243

44+
const limitedToSegmentId = parseLimitedToSegmentId(container.dataset)
45+
const preloadedSegments = parsePreloadedSegments(container.dataset)
46+
const limitedToSegment = getLimitedToSegment(
47+
limitedToSegmentId,
48+
preloadedSegments
49+
)
50+
4351
try {
44-
redirectForLegacyParams(window.location, window.history)
52+
maybeDoFERedirect(window.location, window.history, limitedToSegment)
4553
} catch (e) {
4654
console.error('Error redirecting in a backwards compatible way', e)
4755
}
@@ -84,8 +92,8 @@ if (container && container.dataset) {
8492
}
8593
>
8694
<SegmentsContextProvider
87-
limitedToSegmentId={parseLimitedToSegmentId(container.dataset)}
88-
preloadedSegments={parsePreloadedSegments(container.dataset)}
95+
limitedToSegment={limitedToSegment}
96+
preloadedSegments={preloadedSegments}
8997
>
9098
<RouterProvider router={router} />
9199
</SegmentsContextProvider>

assets/js/dashboard/filtering/segments-context.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('SegmentsContext functions', () => {
3535
test('deleteOne works', () => {
3636
render(
3737
<SegmentsContextProvider
38-
limitedToSegmentId={null}
38+
limitedToSegment={null}
3939
preloadedSegments={[segmentOpenSource, segmentAPAC]}
4040
>
4141
<TestComponent />
@@ -53,7 +53,7 @@ describe('SegmentsContext functions', () => {
5353
test('addOne adds to head of list', async () => {
5454
render(
5555
<SegmentsContextProvider
56-
limitedToSegmentId={null}
56+
limitedToSegment={null}
5757
preloadedSegments={[segmentAPAC]}
5858
>
5959
<TestComponent />
@@ -72,7 +72,7 @@ describe('SegmentsContext functions', () => {
7272
test('updateOne works: updated segment is at head of list', () => {
7373
render(
7474
<SegmentsContextProvider
75-
limitedToSegmentId={null}
75+
limitedToSegment={null}
7676
preloadedSegments={[segmentOpenSource, segmentAPAC]}
7777
>
7878
<TestComponent />

assets/js/dashboard/filtering/segments-context.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,29 @@ export function parseLimitedToSegmentId(dataset: DOMStringMap): number | null {
2121
return JSON.parse(dataset.limitedToSegmentId!)
2222
}
2323

24+
export function getLimitedToSegment(
25+
limitedToSegmentId: number | null,
26+
preloadedSegments: SavedSegments
27+
): Pick<SavedSegment, 'id' | 'name'> | null {
28+
if (limitedToSegmentId !== null) {
29+
return preloadedSegments.find((s) => s.id === limitedToSegmentId) ?? null
30+
}
31+
return null
32+
}
33+
2434
type ChangeSegmentState = (
2535
segment: (SavedSegment | SavedSegmentPublic) & { segment_data: SegmentData }
2636
) => void
2737

2838
const initialValue: {
2939
segments: SavedSegments
30-
limitedToSegmentId: number | null
40+
limitedToSegment: Pick<SavedSegment, 'id' | 'name'> | null
3141
updateOne: ChangeSegmentState
3242
addOne: ChangeSegmentState
3343
removeOne: ChangeSegmentState
3444
} = {
3545
segments: [],
36-
limitedToSegmentId: null,
46+
limitedToSegment: null,
3747
updateOne: () => {},
3848
addOne: () => {},
3949
removeOne: () => {}
@@ -47,11 +57,11 @@ export const useSegmentsContext = () => {
4757

4858
export const SegmentsContextProvider = ({
4959
preloadedSegments,
50-
limitedToSegmentId,
60+
limitedToSegment,
5161
children
5262
}: {
5363
preloadedSegments: SavedSegments
54-
limitedToSegmentId: number | null
64+
limitedToSegment: Pick<SavedSegment, 'id' | 'name'> | null
5565
children: ReactNode
5666
}) => {
5767
const [segments, setSegments] = useState(preloadedSegments)
@@ -83,7 +93,7 @@ export const SegmentsContextProvider = ({
8393
<SegmentsContext.Provider
8494
value={{
8595
segments,
86-
limitedToSegmentId: limitedToSegmentId ?? null,
96+
limitedToSegment,
8797
removeOne,
8898
updateOne,
8999
addOne

assets/js/dashboard/nav-menu/filter-menu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
5050
const columns = useMemo(() => getFilterListItems(site), [site])
5151
const buttonRef = useRef<HTMLButtonElement>(null)
5252
const panelRef = useRef<HTMLDivElement>(null)
53-
const { limitedToSegmentId } = useSegmentsContext()
53+
const { limitedToSegment } = useSegmentsContext()
5454

5555
return (
5656
<>
@@ -109,7 +109,7 @@ const FilterMenuItems = ({ closeDropdown }: { closeDropdown: () => void }) => {
109109
</div>
110110
))}
111111
</div>
112-
{limitedToSegmentId === null && (
112+
{limitedToSegment === null && (
113113
<SearchableSegmentsSection
114114
closeList={closeDropdown}
115115
tooltipContainerRef={panelRef}

assets/js/dashboard/navigation/use-app-navigate.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
LinkProps
1010
} from 'react-router-dom'
1111
import { parseSearch, stringifySearch } from '../util/url-search-params'
12+
import { useSegmentsContext } from '../filtering/segments-context'
13+
import { getSearchToSetSegmentFilter } from '../filtering/segments'
1214

1315
export type AppNavigationTarget = {
1416
/**
@@ -44,11 +46,24 @@ const getNavigateToOptions = (
4446

4547
export const useGetNavigateOptions = () => {
4648
const location = useLocation()
49+
const { limitedToSegment } = useSegmentsContext()
50+
4751
const getToOptions = useCallback(
4852
({ path, params, search }: AppNavigationTarget) => {
49-
return getNavigateToOptions(location.search, { path, params, search })
53+
const wrappedSearch: typeof search = (searchRecord) => {
54+
const updatedSearchRecord =
55+
typeof search === 'function' ? search(searchRecord) : searchRecord
56+
return limitedToSegment
57+
? getSearchToSetSegmentFilter(limitedToSegment)(updatedSearchRecord)
58+
: updatedSearchRecord
59+
}
60+
return getNavigateToOptions(location.search, {
61+
path,
62+
params,
63+
search: wrappedSearch
64+
})
5065
},
51-
[location.search]
66+
[location.search, limitedToSegment]
5267
)
5368
return getToOptions
5469
}

assets/js/dashboard/util/url-search-params.test.ts

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Filter } from '../query'
22
import {
33
encodeURIComponentPermissive,
4+
getSearchWithEnforcedSegment,
45
isSearchEntryDefined,
5-
getRedirectTarget,
6+
maybeGetLatestReadableSearch,
67
parseFilter,
78
parseLabelsEntry,
89
parseSearch,
@@ -206,57 +207,45 @@ describe(`${stringifySearch.name}`, () => {
206207
})
207208
})
208209

209-
describe(`${getRedirectTarget.name}`, () => {
210+
describe(`${maybeGetLatestReadableSearch.name}`, () => {
210211
it.each([
211212
[''],
212213
['?auth=_Y6YOjUl2beUJF_XzG1hk&theme=light&background=%23ee00ee'],
213214
['?keybindHint=Escape&with_imported=true'],
214215
['?f=is,page,/blog/:category/:article-name&date=2024-10-10&period=day'],
215216
['?f=is,country,US&l=US,United%20States']
216-
])('for modern search %p returns null', (search) => {
217-
expect(
218-
getRedirectTarget({
219-
pathname: '/example.com%2Fdeep%2Fpath',
220-
search
221-
} as Location)
222-
).toBeNull()
217+
])('for modern search string %p returns null', (search) => {
218+
expect(maybeGetLatestReadableSearch(search)).toBeNull()
223219
})
224220

225-
it('returns updated URL for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => {
226-
const pathname = '/'
221+
it('returns updated search string for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => {
227222
const search =
228223
'?filters=((is,exit_page,(/plausible.io)),(is,source,(Brave)),(is,city,(993800)))&labels=(993800:Johannesburg)'
229224
const expectedUpdatedSearch =
230225
'?f=is,exit_page,/plausible.io&f=is,source,Brave&f=is,city,993800&l=993800,Johannesburg&r=v2'
231-
expect(
232-
getRedirectTarget({
233-
pathname,
234-
search
235-
} as Location)
236-
).toEqual(`${pathname}${expectedUpdatedSearch}`)
237-
expect(
238-
getRedirectTarget({
239-
pathname,
240-
search: expectedUpdatedSearch
241-
} as Location)
242-
).toBeNull()
226+
expect(maybeGetLatestReadableSearch(search)).toEqual(expectedUpdatedSearch)
227+
expect(maybeGetLatestReadableSearch(expectedUpdatedSearch)).toBeNull()
243228
})
244229

245-
it('returns updated URL for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => {
246-
const pathname = '/'
230+
it('returns updated search string for page=... style filters (v1), and running the updated value through the function again returns null (no redirect loop)', () => {
247231
const search = '?page=/docs'
248232
const expectedUpdatedSearch = '?f=is,page,/docs&r=v1'
233+
expect(maybeGetLatestReadableSearch(search)).toEqual(expectedUpdatedSearch)
234+
expect(maybeGetLatestReadableSearch(expectedUpdatedSearch)).toBeNull()
235+
})
236+
})
237+
238+
describe(`${getSearchWithEnforcedSegment.name}`, () => {
239+
it('adds enforced segment appropriately, and running the updated value through the function again returns the same value', () => {
240+
const segment = { id: 100, name: 'Eastern Europe' }
241+
const search = '?auth=foo&embed=true'
242+
const expectedUpdatedSearch =
243+
'?f=is,segment,100&l=s-100,Eastern%20Europe&auth=foo&embed=true'
244+
expect(getSearchWithEnforcedSegment(search, segment)).toEqual(
245+
expectedUpdatedSearch
246+
)
249247
expect(
250-
getRedirectTarget({
251-
pathname,
252-
search
253-
} as Location)
254-
).toEqual(`${pathname}${expectedUpdatedSearch}`)
255-
expect(
256-
getRedirectTarget({
257-
pathname,
258-
search: expectedUpdatedSearch
259-
} as Location)
260-
).toBeNull()
248+
getSearchWithEnforcedSegment(expectedUpdatedSearch, segment)
249+
).toEqual(expectedUpdatedSearch)
261250
})
262251
})

assets/js/dashboard/util/url-search-params.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
getSearchToSetSegmentFilter,
3+
SavedSegment
4+
} from '../filtering/segments'
15
import { Filter, FilterClauseLabels } from '../query'
26
import { v1 } from './url-search-params-v1'
37
import { v2 } from './url-search-params-v2'
@@ -230,8 +234,10 @@ function isAlreadyRedirected(searchParams: URLSearchParams) {
230234
The purpose of this function is to redirect users from one of the previous versions to the current version,
231235
so previous dashboard links still work.
232236
*/
233-
export function getRedirectTarget(windowLocation: Location): null | string {
234-
const searchParams = new URLSearchParams(windowLocation.search)
237+
export function maybeGetLatestReadableSearch(
238+
searchString: string
239+
): null | string {
240+
const searchParams = new URLSearchParams(searchString)
235241
if (isAlreadyRedirected(searchParams)) {
236242
return null
237243
}
@@ -242,27 +248,67 @@ export function getRedirectTarget(windowLocation: Location): null | string {
242248

243249
const isV2 = v2.isV2(searchParams)
244250
if (isV2) {
245-
return `${windowLocation.pathname}${stringifySearch({ ...v2.parseSearch(windowLocation.search), [REDIRECTED_SEARCH_PARAM_NAME]: 'v2' })}`
251+
return stringifySearch({
252+
...v2.parseSearch(searchString),
253+
[REDIRECTED_SEARCH_PARAM_NAME]: 'v2'
254+
})
246255
}
247256

248-
const searchRecord = v2.parseSearch(windowLocation.search)
257+
const searchRecord = v2.parseSearch(searchString)
249258
const isV1 = v1.isV1(searchRecord)
250259

251260
if (!isV1) {
252261
return null
253262
}
254263

255-
return `${windowLocation.pathname}${stringifySearch({ ...v1.parseSearchRecord(searchRecord), [REDIRECTED_SEARCH_PARAM_NAME]: 'v1' })}`
264+
return stringifySearch({
265+
...v1.parseSearchRecord(searchRecord),
266+
[REDIRECTED_SEARCH_PARAM_NAME]: 'v1'
267+
})
268+
}
269+
270+
/**
271+
* It's possible to set a particular segment to be always applied on the data on dashboards accessed with a shared link.
272+
* This function ensures that the particular segment filter is set to the URL string on initial page load.
273+
* Other functions ensure that it can't be removed.
274+
*/
275+
export function getSearchWithEnforcedSegment(
276+
searchString: string,
277+
enforcedSegment: Pick<SavedSegment, 'id' | 'name'>
278+
): string {
279+
const searchRecord = parseSearch(searchString)
280+
return stringifySearch(
281+
getSearchToSetSegmentFilter(enforcedSegment)(searchRecord)
282+
)
256283
}
257284

258285
/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */
259-
export function redirectForLegacyParams(
286+
export function maybeDoFERedirect(
260287
windowLocation: Location,
261-
windowHistory: History
288+
windowHistory: History,
289+
enforcedSegment: Pick<SavedSegment, 'id' | 'name'> | null
262290
) {
263-
const redirectTargetURL = getRedirectTarget(windowLocation)
264-
if (redirectTargetURL === null) {
291+
const originalSearchString = windowLocation.search
292+
293+
let updatedSearchString = maybeGetLatestReadableSearch(originalSearchString)
294+
295+
if (enforcedSegment) {
296+
updatedSearchString = getSearchWithEnforcedSegment(
297+
updatedSearchString ?? originalSearchString,
298+
enforcedSegment
299+
)
300+
}
301+
302+
if (
303+
updatedSearchString === null ||
304+
updatedSearchString === originalSearchString
305+
) {
265306
return
266307
}
267-
windowHistory.pushState({}, '', redirectTargetURL)
308+
309+
windowHistory.pushState(
310+
{},
311+
'',
312+
`${windowLocation.pathname}${updatedSearchString}`
313+
)
268314
}

assets/test-utils/app-context-providers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const TestContextProviders = ({
7878
}
7979
}
8080
>
81-
<SegmentsContextProvider limitedToSegmentId={null} preloadedSegments={preloaded?.segments ?? []}>
81+
<SegmentsContextProvider limitedToSegment={null} preloadedSegments={preloaded?.segments ?? []}>
8282
<MemoryRouter
8383
basename={getRouterBasepath(site)}
8484
initialEntries={defaultInitialEntries}

0 commit comments

Comments
 (0)