@startsimpli/ui 0.4.83 → 0.4.85
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 +1 -1
- package/src/components/index.ts +2 -4
- package/src/components/navigation/__tests__/entity-switcher.test.tsx +258 -0
- package/src/components/navigation/__tests__/switcher-slot.test.tsx +102 -0
- package/src/components/navigation/app-shell.tsx +8 -0
- package/src/components/navigation/entity-switcher.tsx +293 -0
- package/src/components/navigation/index.ts +6 -0
- package/src/components/navigation/sidebar.tsx +13 -0
- package/src/components/team/MembersSection.tsx +7 -0
- package/src/components/team/MembersTable.tsx +38 -3
- package/src/components/team/__tests__/member-detail.test.tsx +223 -0
- package/src/components/team/members-table-default-class-names.ts +3 -0
- package/src/components/team/pages/MemberDetailPanel.tsx +221 -0
- package/src/components/team/pages/index.ts +10 -0
- package/src/components/team/pages/member-detail-panel-default-class-names.ts +68 -0
- package/src/components/team/types.ts +10 -0
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -50,10 +50,8 @@ export type {
|
|
|
50
50
|
// UnifiedTable utils
|
|
51
51
|
export * from './unified-table/utils'
|
|
52
52
|
|
|
53
|
-
// Export Navigation
|
|
54
|
-
export * from './navigation
|
|
55
|
-
export * from './navigation/app-shell'
|
|
56
|
-
export * from './navigation/grouped-nav'
|
|
53
|
+
// Export Navigation (Sidebar, AppShell, GroupedNav, EntitySwitcher)
|
|
54
|
+
export * from './navigation'
|
|
57
55
|
|
|
58
56
|
// Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
|
|
59
57
|
export * from './workspace'
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntitySwitcher — generic searchable "which record am I looking at?" control
|
|
3
|
+
* (startsim-768w.21.1).
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately entity-agnostic: items are `{ id, label, sublabel?, badge? }`,
|
|
6
|
+
* so the same control switches foundries, companies, projects or workspaces.
|
|
7
|
+
* These tests pin the contract the rail (and later OrgSwitcher, .21.4) relies on.
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { EntitySwitcher, type EntitySwitcherItem } from '../entity-switcher'
|
|
12
|
+
|
|
13
|
+
const items: EntitySwitcherItem[] = [
|
|
14
|
+
{ id: 'acme', label: 'Acme Robotics', sublabel: 'acme' },
|
|
15
|
+
{ id: 'globex', label: 'Globex', sublabel: 'globex-industries' },
|
|
16
|
+
{ id: 'initech', label: 'Initech', sublabel: 'initech', badge: <span data-testid="badge-initech">live</span> },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function setup(props: Partial<React.ComponentProps<typeof EntitySwitcher>> = {}) {
|
|
20
|
+
const onSelect = jest.fn()
|
|
21
|
+
const utils = render(
|
|
22
|
+
<EntitySwitcher items={items} currentId="acme" onSelect={onSelect} {...props} />
|
|
23
|
+
)
|
|
24
|
+
return { onSelect, ...utils }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('EntitySwitcher — trigger', () => {
|
|
28
|
+
it("renders the current item's label on the trigger", () => {
|
|
29
|
+
setup()
|
|
30
|
+
expect(screen.getByRole('combobox')).toHaveTextContent('Acme Robotics')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('exposes combobox a11y wiring on the trigger', async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
setup()
|
|
36
|
+
const trigger = screen.getByRole('combobox')
|
|
37
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
38
|
+
|
|
39
|
+
await user.click(trigger)
|
|
40
|
+
|
|
41
|
+
const listbox = await screen.findByRole('listbox')
|
|
42
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
43
|
+
expect(trigger).toHaveAttribute('aria-controls', listbox.id)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('takes a triggerClassName override so it can sit on a dark rail', () => {
|
|
47
|
+
setup({ triggerClassName: 'my-dark-trigger' })
|
|
48
|
+
expect(screen.getByRole('combobox')).toHaveClass('my-dark-trigger')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('EntitySwitcher — opening and filtering', () => {
|
|
53
|
+
it('opening the trigger reveals a labelled filter input and every item', async () => {
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
setup()
|
|
56
|
+
|
|
57
|
+
await user.click(screen.getByRole('combobox'))
|
|
58
|
+
|
|
59
|
+
// Labelled (accessible name), not a bare unlabelled textbox.
|
|
60
|
+
const filter = await screen.findByRole('textbox', { name: /search|filter/i })
|
|
61
|
+
expect(filter).toBeInTheDocument()
|
|
62
|
+
|
|
63
|
+
const options = screen.getAllByRole('option')
|
|
64
|
+
expect(options).toHaveLength(3)
|
|
65
|
+
expect(options.map((o) => o.textContent)).toEqual(
|
|
66
|
+
expect.arrayContaining([expect.stringContaining('Acme Robotics')])
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('filters on the label, case-insensitively', async () => {
|
|
71
|
+
const user = userEvent.setup()
|
|
72
|
+
setup()
|
|
73
|
+
await user.click(screen.getByRole('combobox'))
|
|
74
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'GLOB')
|
|
75
|
+
|
|
76
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
|
|
77
|
+
expect(screen.getByRole('option')).toHaveTextContent('Globex')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('filters on the sublabel too, case-insensitively', async () => {
|
|
81
|
+
const user = userEvent.setup()
|
|
82
|
+
setup()
|
|
83
|
+
await user.click(screen.getByRole('combobox'))
|
|
84
|
+
// "industries" only appears in Globex's sublabel, never in a label.
|
|
85
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'INDUSTRIES')
|
|
86
|
+
|
|
87
|
+
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1))
|
|
88
|
+
expect(screen.getByRole('option')).toHaveTextContent('Globex')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders an explicit empty state when nothing matches, not a blank popover', async () => {
|
|
92
|
+
const user = userEvent.setup()
|
|
93
|
+
setup({ emptyLabel: 'No foundries match' })
|
|
94
|
+
await user.click(screen.getByRole('combobox'))
|
|
95
|
+
await user.type(await screen.findByRole('textbox', { name: /search|filter/i }), 'zzzz')
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(screen.queryAllByRole('option')).toHaveLength(0))
|
|
98
|
+
expect(screen.getByText('No foundries match')).toBeInTheDocument()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('hides the filter input when searchable={false}', async () => {
|
|
102
|
+
const user = userEvent.setup()
|
|
103
|
+
setup({ searchable: false })
|
|
104
|
+
await user.click(screen.getByRole('combobox'))
|
|
105
|
+
|
|
106
|
+
await screen.findByRole('listbox')
|
|
107
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
|
108
|
+
expect(screen.getAllByRole('option')).toHaveLength(3)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('renders a per-item badge when supplied', async () => {
|
|
112
|
+
const user = userEvent.setup()
|
|
113
|
+
setup()
|
|
114
|
+
await user.click(screen.getByRole('combobox'))
|
|
115
|
+
expect(await screen.findByTestId('badge-initech')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Regression (startsim-768w.21.1.3): the sublabel used to be hard-wired to
|
|
119
|
+
// `text-muted-foreground`, a colour chosen for `bg-popover`. On the
|
|
120
|
+
// highlighted row — which paints `bg-accent` — that grey-on-accent measured
|
|
121
|
+
// 1.1:1 in the browser, i.e. effectively invisible. The sublabel must track
|
|
122
|
+
// the row's own foreground.
|
|
123
|
+
it('switches the sublabel to the accent foreground on the highlighted row', async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
setup()
|
|
126
|
+
await user.click(screen.getByRole('combobox'))
|
|
127
|
+
await screen.findByRole('listbox')
|
|
128
|
+
|
|
129
|
+
const sublabelOf = (name: RegExp) =>
|
|
130
|
+
screen.getByRole('option', { name }).querySelector('span > span.text-xs')!
|
|
131
|
+
|
|
132
|
+
// Opens highlighted on the current item (Acme Robotics).
|
|
133
|
+
await waitFor(() =>
|
|
134
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
135
|
+
'data-highlighted',
|
|
136
|
+
'true'
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
expect(sublabelOf(/Acme Robotics/)).toHaveClass('text-accent-foreground/80')
|
|
140
|
+
expect(sublabelOf(/Acme Robotics/)).not.toHaveClass('text-muted-foreground')
|
|
141
|
+
|
|
142
|
+
// ...and the un-highlighted rows keep the muted treatment.
|
|
143
|
+
expect(sublabelOf(/Globex/)).toHaveClass('text-muted-foreground')
|
|
144
|
+
expect(sublabelOf(/Globex/)).not.toHaveClass('text-accent-foreground/80')
|
|
145
|
+
|
|
146
|
+
// The treatment follows the highlight rather than the selection.
|
|
147
|
+
await user.keyboard('{ArrowDown}')
|
|
148
|
+
await waitFor(() =>
|
|
149
|
+
expect(sublabelOf(/Globex/)).toHaveClass('text-accent-foreground/80')
|
|
150
|
+
)
|
|
151
|
+
expect(sublabelOf(/Acme Robotics/)).toHaveClass('text-muted-foreground')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('EntitySwitcher — a11y of the list', () => {
|
|
156
|
+
it('renders role=listbox with role=option children and marks the current one aria-selected', async () => {
|
|
157
|
+
const user = userEvent.setup()
|
|
158
|
+
setup()
|
|
159
|
+
await user.click(screen.getByRole('combobox'))
|
|
160
|
+
|
|
161
|
+
const listbox = await screen.findByRole('listbox')
|
|
162
|
+
expect(listbox).toBeInTheDocument()
|
|
163
|
+
|
|
164
|
+
const options = screen.getAllByRole('option')
|
|
165
|
+
expect(options).toHaveLength(3)
|
|
166
|
+
const current = options.find((o) => o.textContent?.includes('Acme Robotics'))!
|
|
167
|
+
expect(current).toHaveAttribute('aria-selected', 'true')
|
|
168
|
+
const other = options.find((o) => o.textContent?.includes('Globex'))!
|
|
169
|
+
expect(other).toHaveAttribute('aria-selected', 'false')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('EntitySwitcher — keyboard', () => {
|
|
174
|
+
it('ArrowDown/ArrowUp move the highlighted option', async () => {
|
|
175
|
+
const user = userEvent.setup()
|
|
176
|
+
setup()
|
|
177
|
+
await user.click(screen.getByRole('combobox'))
|
|
178
|
+
const filter = await screen.findByRole('textbox', { name: /search|filter/i })
|
|
179
|
+
|
|
180
|
+
// Opens highlighted on the current item (index 0).
|
|
181
|
+
await waitFor(() =>
|
|
182
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
183
|
+
'data-highlighted',
|
|
184
|
+
'true'
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
expect(filter).toHaveAttribute(
|
|
188
|
+
'aria-activedescendant',
|
|
189
|
+
screen.getByRole('option', { name: /Acme Robotics/ }).id
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
await user.keyboard('{ArrowDown}')
|
|
193
|
+
await waitFor(() =>
|
|
194
|
+
expect(screen.getByRole('option', { name: /Globex/ })).toHaveAttribute('data-highlighted', 'true')
|
|
195
|
+
)
|
|
196
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
197
|
+
'data-highlighted',
|
|
198
|
+
'false'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
await user.keyboard('{ArrowUp}')
|
|
202
|
+
await waitFor(() =>
|
|
203
|
+
expect(screen.getByRole('option', { name: /Acme Robotics/ })).toHaveAttribute(
|
|
204
|
+
'data-highlighted',
|
|
205
|
+
'true'
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('Enter selects the highlighted option', async () => {
|
|
211
|
+
const user = userEvent.setup()
|
|
212
|
+
const { onSelect } = setup()
|
|
213
|
+
await user.click(screen.getByRole('combobox'))
|
|
214
|
+
await screen.findByRole('listbox')
|
|
215
|
+
|
|
216
|
+
await user.keyboard('{ArrowDown}')
|
|
217
|
+
await user.keyboard('{Enter}')
|
|
218
|
+
|
|
219
|
+
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
220
|
+
expect(onSelect).toHaveBeenCalledWith('globex')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('Escape closes the popover and restores focus to the trigger', async () => {
|
|
224
|
+
const user = userEvent.setup()
|
|
225
|
+
const { onSelect } = setup()
|
|
226
|
+
const trigger = screen.getByRole('combobox')
|
|
227
|
+
await user.click(trigger)
|
|
228
|
+
await screen.findByRole('listbox')
|
|
229
|
+
|
|
230
|
+
await user.keyboard('{Escape}')
|
|
231
|
+
|
|
232
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
233
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
234
|
+
await waitFor(() => expect(trigger).toHaveFocus())
|
|
235
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('EntitySwitcher — selection', () => {
|
|
240
|
+
it("clicking an item calls onSelect with that item's id and closes", async () => {
|
|
241
|
+
const user = userEvent.setup()
|
|
242
|
+
const { onSelect } = setup()
|
|
243
|
+
await user.click(screen.getByRole('combobox'))
|
|
244
|
+
|
|
245
|
+
await user.click(await screen.findByRole('option', { name: /Initech/ }))
|
|
246
|
+
|
|
247
|
+
expect(onSelect).toHaveBeenCalledWith('initech')
|
|
248
|
+
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('selecting the already-current item still reports it (caller decides to no-op)', async () => {
|
|
252
|
+
const user = userEvent.setup()
|
|
253
|
+
const { onSelect } = setup()
|
|
254
|
+
await user.click(screen.getByRole('combobox'))
|
|
255
|
+
await user.click(await screen.findByRole('option', { name: /Acme Robotics/ }))
|
|
256
|
+
expect(onSelect).toHaveBeenCalledWith('acme')
|
|
257
|
+
})
|
|
258
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sidebar's contextual `switcher` slot (startsim-768w.21.1).
|
|
3
|
+
*
|
|
4
|
+
* Control-plane apps need a "which record am I scoped to?" control mounted in
|
|
5
|
+
* the rail itself — directly under the brand, above the nav sections — instead
|
|
6
|
+
* of round-tripping through an index page. The slot is a plain ReactNode so the
|
|
7
|
+
* rail stays generic; the foundry app passes an <EntitySwitcher>.
|
|
8
|
+
*/
|
|
9
|
+
import { render, screen } from '@testing-library/react'
|
|
10
|
+
import { Sidebar } from '../sidebar'
|
|
11
|
+
import { AppShell } from '../app-shell'
|
|
12
|
+
|
|
13
|
+
jest.mock('@startsimpli/auth', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
useAuth: () => ({
|
|
16
|
+
user: { id: 'u1', email: 'q@x.com' },
|
|
17
|
+
isLoading: false,
|
|
18
|
+
logout: jest.fn().mockResolvedValue(undefined),
|
|
19
|
+
}),
|
|
20
|
+
buildCentralAuthUrl: (flow: string, opts: { app: string }) =>
|
|
21
|
+
`https://auth.startsimpli.com/${flow}?app=${opts.app}`,
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const sections = [
|
|
25
|
+
{ title: 'Main', links: [{ href: '/', label: 'Dashboard' }] },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** True when `a` appears before `b` in document order. */
|
|
29
|
+
function precedes(a: Element, b: Element) {
|
|
30
|
+
// eslint-disable-next-line no-bitwise
|
|
31
|
+
return Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('Sidebar switcher slot', () => {
|
|
35
|
+
it('renders the switcher between the logo and the nav', () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<Sidebar
|
|
38
|
+
appName="Foundry"
|
|
39
|
+
sections={sections}
|
|
40
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const switcher = screen.getByTestId('sw')
|
|
45
|
+
const logo = screen.getByText('Foundry')
|
|
46
|
+
const nav = container.querySelector('nav')!
|
|
47
|
+
|
|
48
|
+
expect(switcher).toBeInTheDocument()
|
|
49
|
+
expect(precedes(logo, switcher)).toBe(true)
|
|
50
|
+
expect(precedes(switcher, nav)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders nothing when no switcher is supplied (every existing caller is unaffected)', () => {
|
|
54
|
+
render(<Sidebar appName="Foundry" sections={sections} />)
|
|
55
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('hides the switcher when the rail is collapsed', () => {
|
|
60
|
+
render(
|
|
61
|
+
<Sidebar
|
|
62
|
+
appName="Foundry"
|
|
63
|
+
sections={sections}
|
|
64
|
+
defaultCollapsed
|
|
65
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('AppShell switcher forwarding', () => {
|
|
73
|
+
it('forwards the switcher through to the Sidebar rail', () => {
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<AppShell
|
|
76
|
+
app="foundry"
|
|
77
|
+
appName="Foundry"
|
|
78
|
+
sections={sections}
|
|
79
|
+
switcher={<div data-testid="sw">Switcher</div>}
|
|
80
|
+
>
|
|
81
|
+
<p>Page content</p>
|
|
82
|
+
</AppShell>
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const switcher = screen.getByTestId('sw')
|
|
86
|
+
const nav = container.querySelector('nav')!
|
|
87
|
+
expect(switcher).toBeInTheDocument()
|
|
88
|
+
expect(precedes(screen.getByText('Foundry'), switcher)).toBe(true)
|
|
89
|
+
expect(precedes(switcher, nav)).toBe(true)
|
|
90
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders without a switcher (prop is optional)', () => {
|
|
94
|
+
render(
|
|
95
|
+
<AppShell app="foundry" appName="Foundry" sections={sections}>
|
|
96
|
+
<p>Page content</p>
|
|
97
|
+
</AppShell>
|
|
98
|
+
)
|
|
99
|
+
expect(screen.queryByTestId('sw')).not.toBeInTheDocument()
|
|
100
|
+
expect(screen.getByText('Page content')).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -36,6 +36,12 @@ export interface AppShellProps {
|
|
|
36
36
|
appName: string;
|
|
37
37
|
/** Sidebar navigation sections (label + href + optional icon). */
|
|
38
38
|
sections: SidebarSection[];
|
|
39
|
+
/**
|
|
40
|
+
* Contextual switcher for the rail, rendered under the brand and above the
|
|
41
|
+
* nav — e.g. an <EntitySwitcher> over the foundries the user can see.
|
|
42
|
+
* Optional, so every existing caller is unaffected.
|
|
43
|
+
*/
|
|
44
|
+
switcher?: ReactNode;
|
|
39
45
|
/** Extra items rendered above "Log out" in the account menu (optional). */
|
|
40
46
|
accountMenuExtra?: ReactNode;
|
|
41
47
|
/** Loading label while the session bootstraps (default "Loading…"). */
|
|
@@ -47,6 +53,7 @@ export function AppShell({
|
|
|
47
53
|
app,
|
|
48
54
|
appName,
|
|
49
55
|
sections,
|
|
56
|
+
switcher,
|
|
50
57
|
accountMenuExtra,
|
|
51
58
|
loadingLabel = 'Loading…',
|
|
52
59
|
children,
|
|
@@ -118,6 +125,7 @@ export function AppShell({
|
|
|
118
125
|
<Sidebar
|
|
119
126
|
appName={appName}
|
|
120
127
|
sections={sections}
|
|
128
|
+
switcher={switcher}
|
|
121
129
|
userDropdown={userDropdown}
|
|
122
130
|
collapsible
|
|
123
131
|
collapsed={collapsed}
|