@igstack/app-catalog-frontend-core 6.4.0 → 6.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +0 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +0 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +5 -3
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +16 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/ui/components/header/Header.js +0 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +8 -1
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +30 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({ sent: [] as unknown[] }))
|
|
7
|
+
|
|
8
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
9
|
+
useMutation: vi.fn((options?: { onSuccess?: () => void }) => ({
|
|
10
|
+
mutate: (vars: unknown) => {
|
|
11
|
+
state.sent.push(vars)
|
|
12
|
+
options?.onSuccess?.()
|
|
13
|
+
},
|
|
14
|
+
isPending: false,
|
|
15
|
+
})),
|
|
16
|
+
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
|
17
|
+
}))
|
|
18
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
19
|
+
useTRPC: vi.fn(() => ({
|
|
20
|
+
feedback: {
|
|
21
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
22
|
+
mine: { queryKey: () => ['feedback', 'mine'] },
|
|
23
|
+
},
|
|
24
|
+
})),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const { RequestAppDialog } =
|
|
28
|
+
await import('~/modules/appCatalog/ui/catalog/RequestAppDialog')
|
|
29
|
+
|
|
30
|
+
describe('RequestAppDialog', () => {
|
|
31
|
+
/**
|
|
32
|
+
* The grid is rendered bare in several tests with no TRPCProvider, so the closed
|
|
33
|
+
* dialog must not reach a tRPC hook. A hook cannot be called conditionally, which is
|
|
34
|
+
* why the gate is a component boundary — and why this test exists: inlining the body
|
|
35
|
+
* back into one component breaks six unrelated grid tests with
|
|
36
|
+
* "useTRPC() can only be used inside of a <TRPCProvider>".
|
|
37
|
+
*/
|
|
38
|
+
it('mounts nothing at all while closed', () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<RequestAppDialog open={false} onOpenChange={() => {}} />,
|
|
41
|
+
)
|
|
42
|
+
expect(container).toBeEmptyDOMElement()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('pre-fills what the visitor searched for', () => {
|
|
46
|
+
render(
|
|
47
|
+
<RequestAppDialog
|
|
48
|
+
open
|
|
49
|
+
onOpenChange={() => {}}
|
|
50
|
+
initialSubject="hoppy budget tool"
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
// They just typed it into the search box; asking again is a toll on the one
|
|
54
|
+
// person who already told us what is missing.
|
|
55
|
+
expect(screen.getByLabelText(/What are you looking for/)).toHaveValue(
|
|
56
|
+
'hoppy budget tool',
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('refuses to send with no name, then sends a catalog-level ask', async () => {
|
|
61
|
+
state.sent = []
|
|
62
|
+
render(<RequestAppDialog open onOpenChange={() => {}} initialSubject="" />)
|
|
63
|
+
|
|
64
|
+
const send = screen.getByRole('button', { name: /Send request/ })
|
|
65
|
+
expect(send).toBeDisabled()
|
|
66
|
+
|
|
67
|
+
await userEvent.type(
|
|
68
|
+
screen.getByLabelText(/What are you looking for/),
|
|
69
|
+
'Grafana',
|
|
70
|
+
)
|
|
71
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
72
|
+
|
|
73
|
+
// No resourceSlug: that is what makes it a request for something absent rather
|
|
74
|
+
// than feedback on an existing entry. The server splits them the same way.
|
|
75
|
+
expect(state.sent).toEqual([{ subject: 'Grafana' }])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('carries the optional detail when given, and omits it when blank', async () => {
|
|
79
|
+
state.sent = []
|
|
80
|
+
render(
|
|
81
|
+
<RequestAppDialog
|
|
82
|
+
open
|
|
83
|
+
onOpenChange={() => {}}
|
|
84
|
+
initialSubject="Grafana"
|
|
85
|
+
/>,
|
|
86
|
+
)
|
|
87
|
+
await userEvent.type(
|
|
88
|
+
screen.getByLabelText(/Anything that would help/),
|
|
89
|
+
'the observability team runs it',
|
|
90
|
+
)
|
|
91
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
92
|
+
|
|
93
|
+
expect(state.sent).toEqual([
|
|
94
|
+
{ subject: 'Grafana', body: 'the observability team runs it' },
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('confirms rather than leaving them wondering whether it sent', async () => {
|
|
99
|
+
state.sent = []
|
|
100
|
+
render(
|
|
101
|
+
<RequestAppDialog
|
|
102
|
+
open
|
|
103
|
+
onOpenChange={() => {}}
|
|
104
|
+
initialSubject="Grafana"
|
|
105
|
+
/>,
|
|
106
|
+
)
|
|
107
|
+
await userEvent.click(screen.getByRole('button', { name: /Send request/ }))
|
|
108
|
+
|
|
109
|
+
// Reporting into silence is how a person learns not to bother.
|
|
110
|
+
expect(screen.getByText(/that is logged/i)).toBeInTheDocument()
|
|
111
|
+
expect(screen.queryByRole('button', { name: /Send request/ })).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -102,6 +102,30 @@ describe('the tab strip', () => {
|
|
|
102
102
|
expect(wroteTab).toBe(true)
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The card opens on the first tab, so naming it in `?tab=` says nothing — and it
|
|
107
|
+
* made every shared link carry a param the sender never chose. Only a tab that is
|
|
108
|
+
* NOT the default belongs in the url.
|
|
109
|
+
*/
|
|
110
|
+
it('keeps the default tab out of the url', () => {
|
|
111
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
112
|
+
|
|
113
|
+
// Go somewhere else, then come back to the first tab.
|
|
114
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
115
|
+
navigate.mockClear()
|
|
116
|
+
fireEvent.click(screen.getByRole('tab', { name: /^overview/i }))
|
|
117
|
+
|
|
118
|
+
const wroteDefault = navigate.mock.calls.some(
|
|
119
|
+
([arg]) =>
|
|
120
|
+
(arg as { search?: Record<string, unknown> }).search?.tab ===
|
|
121
|
+
'overview',
|
|
122
|
+
)
|
|
123
|
+
expect(
|
|
124
|
+
wroteDefault,
|
|
125
|
+
'returning to the default tab must clear ?tab=, not set it',
|
|
126
|
+
).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
105
129
|
it('opens on the tab named in the url', () => {
|
|
106
130
|
search.tab = 'documentation'
|
|
107
131
|
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
@@ -13,6 +13,7 @@ import { useUiSettings } from '~/context/UiSettingsContext'
|
|
|
13
13
|
import type { AreaIcon } from '~/types/uiSettings'
|
|
14
14
|
import { AppCatalogContext } from '../../context/AppCatalogContext'
|
|
15
15
|
import { areaLabel, groupByArea } from '../../utils/areaGrouping'
|
|
16
|
+
import { RequestAppDialog } from './RequestAppDialog'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Adaptive-home discovery spine (issue #38, increment 1) — matches the
|
|
@@ -347,6 +348,9 @@ function SearchResultsList({
|
|
|
347
348
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
|
348
349
|
() => new Set(),
|
|
349
350
|
)
|
|
351
|
+
// Asking for something absent. Local state rather than a url param: a half-typed
|
|
352
|
+
// request has no business in a shareable link.
|
|
353
|
+
const [requestOpen, setRequestOpen] = useState(false)
|
|
350
354
|
|
|
351
355
|
const results = useMemo(
|
|
352
356
|
() => searchResources(apps, searchValue),
|
|
@@ -483,6 +487,26 @@ function SearchResultsList({
|
|
|
483
487
|
: `${matchCount} result${matchCount === 1 ? '' : 's'}`}
|
|
484
488
|
</div>
|
|
485
489
|
|
|
490
|
+
{/* A zero-result search is the most informative moment in the catalog: someone
|
|
491
|
+
has said exactly what they expected to find. Showing only "No results"
|
|
492
|
+
throws that away and leaves them with nowhere to go. */}
|
|
493
|
+
{matchCount === 0 && !didDeprecatedFallback && (
|
|
494
|
+
<div className="mb-3 px-1">
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
onClick={() => setRequestOpen(true)}
|
|
498
|
+
className="text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground"
|
|
499
|
+
>
|
|
500
|
+
Not in the catalog? Ask for it to be added
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
<RequestAppDialog
|
|
505
|
+
open={requestOpen}
|
|
506
|
+
onOpenChange={setRequestOpen}
|
|
507
|
+
initialSubject={searchValue}
|
|
508
|
+
/>
|
|
509
|
+
|
|
486
510
|
{/* #11: deprecated-only fallback notice */}
|
|
487
511
|
{didDeprecatedFallback && (
|
|
488
512
|
<div
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { useTRPC } from '~/api/infra/trpc'
|
|
4
|
+
import { Button } from '~/ui/button'
|
|
5
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/ui/dialog'
|
|
6
|
+
import { cn } from '~/lib/utils'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Asking for something that is not in the catalog yet.
|
|
10
|
+
*
|
|
11
|
+
* The search box is where people discover the gap — they type a tool's name, get
|
|
12
|
+
* nothing, and until now had nowhere to go. A zero-result search is the single most
|
|
13
|
+
* informative moment in the catalog: someone has told us exactly what they expected to
|
|
14
|
+
* find. Throwing that away and showing only "No results" wastes it.
|
|
15
|
+
*
|
|
16
|
+
* This is a catalog-level ask rather than feedback on an entry: `feedback.add` with a
|
|
17
|
+
* `subject` and NO `resourceSlug`. The server already distinguishes the two that way,
|
|
18
|
+
* and the tracker lists both.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Same shape the input primitive uses, minus the fixed height. */
|
|
22
|
+
const FIELD_CLASSES =
|
|
23
|
+
'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'
|
|
24
|
+
|
|
25
|
+
const MAX_SUBJECT = 120
|
|
26
|
+
|
|
27
|
+
interface RequestAppDialogProps {
|
|
28
|
+
open: boolean
|
|
29
|
+
onOpenChange: (open: boolean) => void
|
|
30
|
+
/** What they searched for. Pre-fills the name, because they just typed it. */
|
|
31
|
+
initialSubject?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mounts nothing until it is opened, which is what keeps `useTRPC()` out of the
|
|
36
|
+
* closed path. The catalog grid is rendered bare in several tests with no
|
|
37
|
+
* TRPCProvider, and a hook cannot be called conditionally — so the gate has to be a
|
|
38
|
+
* component boundary rather than an `if` inside one.
|
|
39
|
+
*/
|
|
40
|
+
export function RequestAppDialog(props: RequestAppDialogProps) {
|
|
41
|
+
if (!props.open) return null
|
|
42
|
+
return <RequestAppDialogBody {...props} />
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RequestAppDialogBody({
|
|
46
|
+
open,
|
|
47
|
+
onOpenChange,
|
|
48
|
+
initialSubject = '',
|
|
49
|
+
}: RequestAppDialogProps) {
|
|
50
|
+
const trpc = useTRPC()
|
|
51
|
+
const queryClient = useQueryClient()
|
|
52
|
+
const [subject, setSubject] = useState(initialSubject)
|
|
53
|
+
const [body, setBody] = useState('')
|
|
54
|
+
const [sent, setSent] = useState(false)
|
|
55
|
+
|
|
56
|
+
// Re-seed when opened for a different search. Keyed on the dialog opening rather
|
|
57
|
+
// than on every keystroke, so it never overwrites something half-typed.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (open) {
|
|
60
|
+
setSubject(initialSubject)
|
|
61
|
+
setBody('')
|
|
62
|
+
setSent(false)
|
|
63
|
+
}
|
|
64
|
+
}, [open, initialSubject])
|
|
65
|
+
|
|
66
|
+
const add = useMutation(
|
|
67
|
+
trpc.feedback.add.mutationOptions({
|
|
68
|
+
onSuccess: () => {
|
|
69
|
+
setSent(true)
|
|
70
|
+
// The tracker counts this, so its badge has to notice.
|
|
71
|
+
void queryClient.invalidateQueries({
|
|
72
|
+
queryKey: trpc.feedback.mine.queryKey(),
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const trimmed = subject.trim()
|
|
79
|
+
const send = () => {
|
|
80
|
+
if (!trimmed) return
|
|
81
|
+
add.mutate({
|
|
82
|
+
subject: trimmed.slice(0, MAX_SUBJECT),
|
|
83
|
+
...(body.trim() ? { body: body.trim() } : {}),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
89
|
+
<DialogContent className="sm:max-w-[480px]">
|
|
90
|
+
<DialogHeader>
|
|
91
|
+
<DialogTitle>
|
|
92
|
+
{sent ? 'Thanks — that is logged' : 'Ask for an app to be added'}
|
|
93
|
+
</DialogTitle>
|
|
94
|
+
</DialogHeader>
|
|
95
|
+
|
|
96
|
+
{sent ? (
|
|
97
|
+
<div className="space-y-3">
|
|
98
|
+
<p className="text-muted-foreground text-sm">
|
|
99
|
+
A maintainer will look at it. You can see what happened to it
|
|
100
|
+
under your own requests.
|
|
101
|
+
</p>
|
|
102
|
+
<Button type="button" size="sm" onClick={() => onOpenChange(false)}>
|
|
103
|
+
Close
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="space-y-3">
|
|
108
|
+
<div className="space-y-1.5">
|
|
109
|
+
<label htmlFor="request-app-name" className="text-sm font-medium">
|
|
110
|
+
What are you looking for?
|
|
111
|
+
</label>
|
|
112
|
+
<input
|
|
113
|
+
id="request-app-name"
|
|
114
|
+
autoFocus
|
|
115
|
+
className={cn(FIELD_CLASSES)}
|
|
116
|
+
placeholder="Name of the tool, or what it does"
|
|
117
|
+
maxLength={MAX_SUBJECT}
|
|
118
|
+
value={subject}
|
|
119
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="space-y-1.5">
|
|
124
|
+
<label
|
|
125
|
+
htmlFor="request-app-detail"
|
|
126
|
+
className="text-sm font-medium"
|
|
127
|
+
>
|
|
128
|
+
Anything that would help us find it{' '}
|
|
129
|
+
<span className="text-muted-foreground font-normal">
|
|
130
|
+
(optional)
|
|
131
|
+
</span>
|
|
132
|
+
</label>
|
|
133
|
+
<textarea
|
|
134
|
+
id="request-app-detail"
|
|
135
|
+
rows={3}
|
|
136
|
+
className={cn(FIELD_CLASSES)}
|
|
137
|
+
placeholder="A link, the team that owns it, what you needed it for"
|
|
138
|
+
value={body}
|
|
139
|
+
onChange={(event) => setBody(event.target.value)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
144
|
+
<Button
|
|
145
|
+
type="button"
|
|
146
|
+
size="sm"
|
|
147
|
+
disabled={!trimmed || add.isPending}
|
|
148
|
+
onClick={send}
|
|
149
|
+
>
|
|
150
|
+
{add.isPending ? 'Sending…' : 'Send request'}
|
|
151
|
+
</Button>
|
|
152
|
+
<span className="text-muted-foreground text-xs">
|
|
153
|
+
Sent under a nickname. No login needed.
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{add.error && (
|
|
158
|
+
<p className="text-destructive text-xs">
|
|
159
|
+
{add.error instanceof Error
|
|
160
|
+
? add.error.message
|
|
161
|
+
: 'Something went wrong'}
|
|
162
|
+
</p>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</DialogContent>
|
|
167
|
+
</Dialog>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -73,10 +73,17 @@ export function DetailTabs({
|
|
|
73
73
|
/** Cancels the dialog's own padding so the tint reaches the card's edges. */
|
|
74
74
|
bandClassName?: string
|
|
75
75
|
}) {
|
|
76
|
+
/**
|
|
77
|
+
* The first tab is what the card opens on, so naming it in the URL says nothing.
|
|
78
|
+
* `encode` returning undefined drops the param, which keeps `?tab=` out of a link
|
|
79
|
+
* until someone has actually chosen something other than the default — and keeps a
|
|
80
|
+
* shared link honest about what the sender was looking at.
|
|
81
|
+
*/
|
|
82
|
+
const defaultTabId = tabs[0]?.id
|
|
76
83
|
const [urlTab, setUrlTab] = useUrlSyncedState<string>({
|
|
77
84
|
key: 'tab',
|
|
78
85
|
defaultValue: '',
|
|
79
|
-
encode: (value) => value
|
|
86
|
+
encode: (value) => (value && value !== defaultTabId ? value : undefined),
|
|
80
87
|
})
|
|
81
88
|
|
|
82
89
|
const ids = tabs.map((t) => t.id)
|
|
@@ -121,6 +121,36 @@ export function AppCatalogPage({
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
// The app whose detail card is open over the catalog backdrop.
|
|
124
|
+
/**
|
|
125
|
+
* Strip the card's own params once no card is open.
|
|
126
|
+
*
|
|
127
|
+
* Clearing them inside the close handler does not hold: `useUrlSyncedState` only
|
|
128
|
+
* ever pushes its state OUT to the url, so the tab strip — still mounted for one
|
|
129
|
+
* more render — sees the url disagreeing with its own state and writes `tab` straight
|
|
130
|
+
* back. Two owners, and the one on its way out gets the last word. Measured on a
|
|
131
|
+
* live host: `?tab=notes` survived the close every time.
|
|
132
|
+
*
|
|
133
|
+
* Doing it here instead means one owner decides, and it decides AFTER the strip has
|
|
134
|
+
* gone, so there is nothing left to argue with. Only the card's params go; the
|
|
135
|
+
* catalog's own state (filters, recents, the deprecated toggle) outlives any card.
|
|
136
|
+
*/
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (selectedSlug || subPageSlug) return
|
|
139
|
+
const stale = ['tab', 'sub', 'qj'].filter(
|
|
140
|
+
(key) => (search as Record<string, unknown>)[key] !== undefined,
|
|
141
|
+
)
|
|
142
|
+
if (stale.length === 0) return
|
|
143
|
+
void navigate({
|
|
144
|
+
to: '/',
|
|
145
|
+
replace: true,
|
|
146
|
+
search: (prev) => {
|
|
147
|
+
const next = { ...(prev as Record<string, unknown>) }
|
|
148
|
+
for (const key of stale) delete next[key]
|
|
149
|
+
return next
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
}, [selectedSlug, subPageSlug, search, navigate])
|
|
153
|
+
|
|
124
154
|
const selectedApp = useMemo(
|
|
125
155
|
() =>
|
|
126
156
|
selectedAppSlug
|