@servicetitan/navigation 14.3.0 → 14.5.0
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/components/titan-layout/layout-sidebar.d.ts.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.js +30 -11
- package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
- package/dist/components/titan-layout/layout-sidebar.module.less +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +69 -107
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less +70 -127
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +2 -0
- package/dist/utils/__tests__/catalog-groups.test.d.ts +2 -0
- package/dist/utils/__tests__/catalog-groups.test.d.ts.map +1 -0
- package/dist/utils/__tests__/side-nav.test.d.ts +2 -0
- package/dist/utils/__tests__/side-nav.test.d.ts.map +1 -0
- package/dist/utils/catalog-groups.d.ts +11 -0
- package/dist/utils/catalog-groups.d.ts.map +1 -0
- package/dist/utils/catalog-groups.js +66 -0
- package/dist/utils/catalog-groups.js.map +1 -0
- package/dist/utils/side-nav.d.ts +17 -0
- package/dist/utils/side-nav.d.ts.map +1 -1
- package/dist/utils/side-nav.js +32 -0
- package/dist/utils/side-nav.js.map +1 -1
- package/package.json +2 -2
- package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +7 -3
- package/src/components/titan-layout/layout-sidebar.module.less +1 -1
- package/src/components/titan-layout/layout-sidebar.tsx +34 -10
- package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +1 -1
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less +70 -127
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.module.less.d.ts +2 -0
- package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +107 -134
- package/src/utils/__tests__/catalog-groups.test.ts +97 -0
- package/src/utils/__tests__/side-nav.test.ts +77 -0
- package/src/utils/catalog-groups.ts +57 -0
- package/src/utils/side-nav.ts +42 -0
|
@@ -4,42 +4,34 @@ import IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg'
|
|
|
4
4
|
import IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';
|
|
5
5
|
import classNames from 'classnames';
|
|
6
6
|
import { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import {
|
|
8
|
+
filterCatalogGroups,
|
|
9
|
+
getCatalogChildren,
|
|
10
|
+
getCatalogSubmenuGroups,
|
|
11
|
+
} from '../../../utils/catalog-groups';
|
|
7
12
|
import { NavigationItemData } from '../../../utils/navigation';
|
|
8
13
|
import { getCounterTag } from '../../../utils/side-nav';
|
|
9
14
|
import { BadgeTag } from '../../badge-tag';
|
|
10
|
-
import {
|
|
15
|
+
import { PinnedNavigationConfig } from '../interface';
|
|
11
16
|
import { useTitanLayoutContext } from '../layout-context';
|
|
12
17
|
import { NavigationCatalogFooter } from './navigation-catalog-footer';
|
|
13
18
|
import * as styles from './navigation-catalog.module.less';
|
|
14
19
|
|
|
15
|
-
/** The pages beneath an item, flattened out of its submenu groups. */
|
|
16
|
-
const childrenOf = (item: NavigationItemData) => {
|
|
17
|
-
const seen = new Set<string>();
|
|
18
|
-
|
|
19
|
-
return (item.submenu?.groups ?? []).flatMap(group =>
|
|
20
|
-
(group.links ?? []).filter(link => {
|
|
21
|
-
if (seen.has(link.id)) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
seen.add(link.id);
|
|
26
|
-
return true;
|
|
27
|
-
})
|
|
28
|
-
);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
20
|
/** Shown in place of the pin control for a destination the user may not unpin. */
|
|
32
21
|
const LockedPin: FC = () => (
|
|
33
22
|
<Tooltip>
|
|
34
23
|
<Tooltip.Trigger>
|
|
35
24
|
<Icon svg={IconLock} className={styles.lock} />
|
|
36
25
|
</Tooltip.Trigger>
|
|
37
|
-
<Tooltip.Content
|
|
26
|
+
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
27
|
+
Cannot be removed from Favorites
|
|
28
|
+
</Tooltip.Content>
|
|
38
29
|
</Tooltip>
|
|
39
30
|
);
|
|
40
31
|
|
|
41
32
|
interface PinToggleProps {
|
|
42
|
-
pinned
|
|
33
|
+
/** Mixed modules stay highlighted but use an outline until all pages are pinned. */
|
|
34
|
+
pinned: boolean | 'mixed';
|
|
43
35
|
label: string;
|
|
44
36
|
onToggle: () => void;
|
|
45
37
|
}
|
|
@@ -58,16 +50,20 @@ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
|
|
|
58
50
|
<Button
|
|
59
51
|
appearance="ghost"
|
|
60
52
|
size="small"
|
|
61
|
-
icon={pinned ? IconStar : IconStarBorder}
|
|
62
|
-
// Only a control that covers nothing but pinned rows is lit.
|
|
53
|
+
icon={pinned === true ? IconStar : IconStarBorder}
|
|
63
54
|
className={classNames(styles.star, pinned && styles.starActive)}
|
|
64
55
|
aria-label={label}
|
|
65
56
|
aria-pressed={pinned}
|
|
57
|
+
data-pendo="global-nav-favorite-star"
|
|
66
58
|
onClick={toggle}
|
|
67
59
|
/>
|
|
68
60
|
</Tooltip.Trigger>
|
|
69
|
-
<Tooltip.Content>
|
|
70
|
-
{pinned
|
|
61
|
+
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
62
|
+
{pinned === 'mixed'
|
|
63
|
+
? 'Add this module to Favorites'
|
|
64
|
+
: pinned
|
|
65
|
+
? 'Remove from Favorites'
|
|
66
|
+
: 'Add to Favorites'}
|
|
71
67
|
</Tooltip.Content>
|
|
72
68
|
</Tooltip>
|
|
73
69
|
);
|
|
@@ -100,7 +96,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
100
96
|
onNavigate,
|
|
101
97
|
}) => {
|
|
102
98
|
const { NavigationComponent } = useTitanLayoutContext();
|
|
103
|
-
const
|
|
99
|
+
const submenuGroups = getCatalogSubmenuGroups(item);
|
|
104
100
|
|
|
105
101
|
/*
|
|
106
102
|
* Minus anything locked — locked destinations are always present, so the module's control
|
|
@@ -108,6 +104,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
108
104
|
*/
|
|
109
105
|
const everything = scopeIds.filter(id => !isLocked(id));
|
|
110
106
|
const anyPinned = everything.some(isPinned);
|
|
107
|
+
const allPinned = everything.length > 0 && everything.every(isPinned);
|
|
111
108
|
const tag = getCounterTag(item.counter, item.tag);
|
|
112
109
|
|
|
113
110
|
return (
|
|
@@ -120,9 +117,9 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
120
117
|
aria-label={item.title}
|
|
121
118
|
>
|
|
122
119
|
{item.icon && (
|
|
123
|
-
<Icon svg={item.icon} size="
|
|
120
|
+
<Icon svg={item.icon} size="medium" className={styles.moduleIcon} />
|
|
124
121
|
)}
|
|
125
|
-
<Text inline className={styles.moduleTitle}>
|
|
122
|
+
<Text inline size="small" className={styles.moduleTitle}>
|
|
126
123
|
{item.title}
|
|
127
124
|
</Text>
|
|
128
125
|
{/*
|
|
@@ -136,110 +133,80 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
136
133
|
* One control for the whole module: favouriting it takes its pages with it, and
|
|
137
134
|
* unfavouriting it takes them away again.
|
|
138
135
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* star on a module the user can plainly see in the navigation.
|
|
136
|
+
* A gold outline marks a partially favourited module. Fill the star only when
|
|
137
|
+
* its full, unfiltered scope is favourited, including pages hidden by search.
|
|
138
|
+
* Clicking the outline adds the remaining pages; clicking the filled star removes all.
|
|
143
139
|
*/}
|
|
144
140
|
{isLocked(item.id) ? (
|
|
145
141
|
<LockedPin />
|
|
146
142
|
) : (
|
|
147
143
|
<PinToggle
|
|
148
|
-
pinned={anyPinned}
|
|
144
|
+
pinned={allPinned ? true : anyPinned ? 'mixed' : false}
|
|
149
145
|
label={
|
|
150
|
-
|
|
146
|
+
allPinned
|
|
151
147
|
? `Unpin ${item.title} from the navigation`
|
|
152
148
|
: `Pin ${item.title} to the navigation`
|
|
153
149
|
}
|
|
154
|
-
onToggle={() => onToggleAll(everything, !
|
|
150
|
+
onToggle={() => onToggleAll(everything, !allPinned)}
|
|
155
151
|
/>
|
|
156
152
|
)}
|
|
157
153
|
</div>
|
|
158
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
154
|
+
{submenuGroups.map(group => (
|
|
155
|
+
<div key={group.links[0].id}>
|
|
156
|
+
{group.title && (
|
|
157
|
+
<Text variant="eyebrow" size="small" className={styles.submenuTitle}>
|
|
158
|
+
{group.title}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
161
|
+
{group.links.map(child => {
|
|
162
|
+
/*
|
|
163
|
+
* Page titles are only unique within their item — "Settings" and "Reports" recur
|
|
164
|
+
* across the catalog — and the row that tells them apart is a separate element, so
|
|
165
|
+
* it is not part of any of these names. Anything reading the rows on their own, a
|
|
166
|
+
* screen reader running through the links or a voice command naming one, would
|
|
167
|
+
* otherwise be given the same name several times.
|
|
168
|
+
*/
|
|
169
|
+
const qualifiedTitle = `${item.title}: ${child.title}`;
|
|
170
|
+
const childPinned = isPinned(child.id);
|
|
171
|
+
const childTag = getCounterTag(child.counter, child.tag);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div key={child.id} className={styles.row}>
|
|
175
|
+
<NavigationComponent
|
|
176
|
+
to={child.to}
|
|
177
|
+
className={classNames(styles.link, styles.childLink)}
|
|
178
|
+
onClick={onNavigate}
|
|
179
|
+
aria-label={qualifiedTitle}
|
|
180
|
+
>
|
|
181
|
+
<Text inline className={styles.childTitle}>
|
|
182
|
+
{child.title}
|
|
183
|
+
</Text>
|
|
184
|
+
{!!childTag && (
|
|
185
|
+
<BadgeTag data={childTag} className={styles.badge} />
|
|
186
|
+
)}
|
|
187
|
+
</NavigationComponent>
|
|
188
|
+
{isLocked(child.id) ? (
|
|
189
|
+
<LockedPin />
|
|
190
|
+
) : (
|
|
191
|
+
<PinToggle
|
|
192
|
+
pinned={childPinned}
|
|
193
|
+
label={
|
|
194
|
+
childPinned
|
|
195
|
+
? `Unpin ${qualifiedTitle} from the navigation`
|
|
196
|
+
: `Pin ${qualifiedTitle} to the navigation`
|
|
197
|
+
}
|
|
198
|
+
onToggle={() => onTogglePin(child.id, item.id)}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
))}
|
|
199
206
|
</div>
|
|
200
207
|
);
|
|
201
208
|
};
|
|
202
209
|
|
|
203
|
-
/**
|
|
204
|
-
* Narrows the catalog to the entries matching `query`.
|
|
205
|
-
*
|
|
206
|
-
* Matching is literal and never cascades: a row survives only when its own title matches. An item
|
|
207
|
-
* whose title matches is kept as a bare row without the pages beneath it, and a section is kept
|
|
208
|
-
* only for whatever survived under it, so the list only ever shrinks as the query grows.
|
|
209
|
-
*/
|
|
210
|
-
const filterGroups = (groups: NavigationCatalogGroup[], query: string) => {
|
|
211
|
-
const needle = query.trim().toLowerCase();
|
|
212
|
-
|
|
213
|
-
if (!needle) {
|
|
214
|
-
return groups;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return groups.reduce<NavigationCatalogGroup[]>((out, group) => {
|
|
218
|
-
const items = group.items.reduce<NavigationItemData[]>((kept, item) => {
|
|
219
|
-
const matchingChildren = childrenOf(item).filter(child =>
|
|
220
|
-
child.title.toLowerCase().includes(needle)
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
if (matchingChildren.length > 0) {
|
|
224
|
-
kept.push({
|
|
225
|
-
...item,
|
|
226
|
-
submenu: { groups: [{ title: '', links: matchingChildren }] },
|
|
227
|
-
});
|
|
228
|
-
} else if (item.title.toLowerCase().includes(needle)) {
|
|
229
|
-
kept.push({ ...item, submenu: undefined });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return kept;
|
|
233
|
-
}, []);
|
|
234
|
-
|
|
235
|
-
if (items.length > 0) {
|
|
236
|
-
out.push({ ...group, items });
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return out;
|
|
240
|
-
}, []);
|
|
241
|
-
};
|
|
242
|
-
|
|
243
210
|
export interface NavigationCatalogProps extends PinnedNavigationConfig {
|
|
244
211
|
open: boolean;
|
|
245
212
|
onClose: () => void;
|
|
@@ -261,6 +228,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
261
228
|
onResetToDefaults,
|
|
262
229
|
}) => {
|
|
263
230
|
const [query, setQuery] = useState('');
|
|
231
|
+
const isSearching = query.trim().length > 0;
|
|
264
232
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
265
233
|
|
|
266
234
|
/*
|
|
@@ -271,7 +239,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
271
239
|
const resolvedGroups = useMemo(() => groups(), [groups]);
|
|
272
240
|
|
|
273
241
|
const visibleGroups = useMemo(
|
|
274
|
-
() =>
|
|
242
|
+
() => filterCatalogGroups(resolvedGroups, query),
|
|
275
243
|
[resolvedGroups, query]
|
|
276
244
|
);
|
|
277
245
|
|
|
@@ -284,7 +252,10 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
284
252
|
new Map(
|
|
285
253
|
resolvedGroups
|
|
286
254
|
.flatMap(group => group.items)
|
|
287
|
-
.map(item => [
|
|
255
|
+
.map(item => [
|
|
256
|
+
item.id,
|
|
257
|
+
[item.id, ...getCatalogChildren(item).map(child => child.id)],
|
|
258
|
+
])
|
|
288
259
|
),
|
|
289
260
|
[resolvedGroups]
|
|
290
261
|
);
|
|
@@ -385,7 +356,9 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
385
356
|
onClear={() => setQuery('')}
|
|
386
357
|
aria-label="Go to"
|
|
387
358
|
/>
|
|
388
|
-
<div
|
|
359
|
+
<div
|
|
360
|
+
className={classNames(styles.results, isSearching && styles.searchResults)}
|
|
361
|
+
>
|
|
389
362
|
{visibleGroups.length === 0 ? (
|
|
390
363
|
<div className={styles.empty}>
|
|
391
364
|
{/*
|
|
@@ -395,7 +368,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
395
368
|
* quoting an empty query at the user does not describe it.
|
|
396
369
|
*/}
|
|
397
370
|
<Text subdued>
|
|
398
|
-
{
|
|
371
|
+
{isSearching ? (
|
|
399
372
|
<Fragment>
|
|
400
373
|
No navigation items match “{query}”.
|
|
401
374
|
</Fragment>
|
|
@@ -405,25 +378,25 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
405
378
|
</Text>
|
|
406
379
|
</div>
|
|
407
380
|
) : (
|
|
408
|
-
<div className={styles.columns}>
|
|
381
|
+
<div className={isSearching ? undefined : styles.columns}>
|
|
409
382
|
{visibleGroups.map(group => (
|
|
410
|
-
<div
|
|
383
|
+
<div
|
|
384
|
+
key={group.title}
|
|
385
|
+
className={isSearching ? undefined : styles.group}
|
|
386
|
+
>
|
|
411
387
|
{/*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* competes with them, and this one does not need the room:
|
|
415
|
-
* it is already set apart by case, tracking and a grey that
|
|
416
|
-
* nothing else on the surface uses. `small` is 0.625rem,
|
|
417
|
-
* too small to take in at a glance once uppercased and
|
|
418
|
-
* letter-spaced.
|
|
388
|
+
* Browse uses titled columns. Search stacks matches at full
|
|
389
|
+
* width, keeping only each module's own submenu headings.
|
|
419
390
|
*/}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
391
|
+
{!isSearching && (
|
|
392
|
+
<Text
|
|
393
|
+
variant="eyebrow"
|
|
394
|
+
size="medium"
|
|
395
|
+
className={styles.groupTitle}
|
|
396
|
+
>
|
|
397
|
+
{group.title}
|
|
398
|
+
</Text>
|
|
399
|
+
)}
|
|
427
400
|
{group.items.map(item => (
|
|
428
401
|
<CatalogRows
|
|
429
402
|
key={item.id}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterCatalogGroups,
|
|
3
|
+
getCatalogChildren,
|
|
4
|
+
getCatalogSubmenuGroups,
|
|
5
|
+
} from '../catalog-groups';
|
|
6
|
+
import { NavigationItemData } from '../navigation';
|
|
7
|
+
|
|
8
|
+
const marketing: NavigationItemData = {
|
|
9
|
+
id: 'marketing',
|
|
10
|
+
title: 'Marketing',
|
|
11
|
+
to: '/marketing',
|
|
12
|
+
icon: undefined,
|
|
13
|
+
iconActive: undefined,
|
|
14
|
+
submenu: {
|
|
15
|
+
groups: [
|
|
16
|
+
{
|
|
17
|
+
title: 'Analytics',
|
|
18
|
+
links: [
|
|
19
|
+
{ id: 'overview', title: 'Marketing Overview', to: '/marketing/overview' },
|
|
20
|
+
{ id: 'one-time', title: 'One-Time Campaigns', to: '/marketing/one-time' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: 'Campaigns',
|
|
25
|
+
links: [
|
|
26
|
+
{ id: 'manager', title: 'Campaign Manager', to: '/marketing/manager' },
|
|
27
|
+
{ id: 'templates', title: 'Email Templates', to: '/marketing/templates' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const catalog = [{ title: 'Demand', items: [marketing] }];
|
|
34
|
+
|
|
35
|
+
describe('catalog submenu groups', () => {
|
|
36
|
+
it('keeps matching pages under their original headings and in source order', () => {
|
|
37
|
+
const result = filterCatalogGroups(catalog, ' CAMPAIGN ');
|
|
38
|
+
expect(result[0].items[0].submenu?.groups).toEqual([
|
|
39
|
+
{ title: 'Analytics', links: [marketing.submenu!.groups[0].links[1]] },
|
|
40
|
+
{ title: 'Campaigns', links: [marketing.submenu!.groups[1].links[0]] },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('omits groups with no matches without changing the full module used for bulk favorites', () => {
|
|
45
|
+
const result = filterCatalogGroups(catalog, 'email');
|
|
46
|
+
expect(result[0].items[0].submenu?.groups).toEqual([
|
|
47
|
+
{ title: 'Campaigns', links: [marketing.submenu!.groups[1].links[1]] },
|
|
48
|
+
]);
|
|
49
|
+
expect(getCatalogChildren(marketing).map(child => child.id)).toEqual([
|
|
50
|
+
'overview',
|
|
51
|
+
'one-time',
|
|
52
|
+
'manager',
|
|
53
|
+
'templates',
|
|
54
|
+
]);
|
|
55
|
+
expect(marketing.submenu!.groups.map(group => group.links.length)).toEqual([2, 2]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders duplicate pages once and omits groups left empty by deduplication', () => {
|
|
59
|
+
const groups = getCatalogSubmenuGroups({
|
|
60
|
+
...marketing,
|
|
61
|
+
submenu: {
|
|
62
|
+
groups: [
|
|
63
|
+
{ ...marketing.submenu!.groups[0], title: ' Analytics ' },
|
|
64
|
+
{ title: 'Duplicate', links: marketing.submenu!.groups[0].links },
|
|
65
|
+
{ title: 'Empty', links: [] },
|
|
66
|
+
marketing.submenu!.groups[1],
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(groups).toEqual(marketing.submenu!.groups);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('retains ungrouped pages without introducing a blank heading', () => {
|
|
74
|
+
const [group] = getCatalogSubmenuGroups({
|
|
75
|
+
...marketing,
|
|
76
|
+
submenu: { groups: [{ title: ' ', links: marketing.submenu!.groups[0].links }] },
|
|
77
|
+
});
|
|
78
|
+
expect(group.title).toBe('');
|
|
79
|
+
expect(group.links).toEqual(marketing.submenu!.groups[0].links);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps module-only matches without adding unrelated child pages', () => {
|
|
83
|
+
const result = filterCatalogGroups(catalog, 'Marketing Overview');
|
|
84
|
+
expect(result[0].items[0].submenu?.groups[0].title).toBe('Analytics');
|
|
85
|
+
const moduleOnly = filterCatalogGroups(
|
|
86
|
+
[{ title: 'Demand', items: [{ ...marketing, title: 'Marketing Pro' }] }],
|
|
87
|
+
'Marketing Pro'
|
|
88
|
+
);
|
|
89
|
+
expect(moduleOnly[0].items[0].submenu).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('restores the full catalog on clear and removes empty results', () => {
|
|
93
|
+
expect(filterCatalogGroups(catalog, ' ')).toBe(catalog);
|
|
94
|
+
expect(filterCatalogGroups(catalog, 'no such destination')).toEqual([]);
|
|
95
|
+
expect(getCatalogSubmenuGroups({ ...marketing, submenu: undefined })).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getModuleActiveMatcher, getModuleDestination } from '../side-nav';
|
|
2
|
+
|
|
3
|
+
describe('getModuleDestination', () => {
|
|
4
|
+
it('routes to the first link of the first non-empty group when a submenu exists', () => {
|
|
5
|
+
expect(
|
|
6
|
+
getModuleDestination('/dispatch/default', {
|
|
7
|
+
groups: [
|
|
8
|
+
{
|
|
9
|
+
title: 'Boards',
|
|
10
|
+
links: [{ id: 'daily', title: 'Daily', to: '/dispatch/daily' }],
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
})
|
|
14
|
+
).toBe('/dispatch/daily');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('skips empty groups to find the first group that actually has links', () => {
|
|
18
|
+
expect(
|
|
19
|
+
getModuleDestination('/reports', {
|
|
20
|
+
groups: [
|
|
21
|
+
{ title: 'Empty', links: [] },
|
|
22
|
+
{
|
|
23
|
+
title: 'Reports',
|
|
24
|
+
links: [{ id: 'sales', title: 'Sales', to: '/reports/sales' }],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
).toBe('/reports/sales');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('falls back to the host-supplied destination when there is no submenu', () => {
|
|
32
|
+
expect(getModuleDestination('/dashboard', undefined)).toBe('/dashboard');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back to the host-supplied destination when the submenu has no links at all', () => {
|
|
36
|
+
expect(
|
|
37
|
+
getModuleDestination('/dashboard', { groups: [{ title: 'Empty', links: [] }] })
|
|
38
|
+
).toBe('/dashboard');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getModuleActiveMatcher', () => {
|
|
43
|
+
const submenu = {
|
|
44
|
+
groups: [
|
|
45
|
+
{
|
|
46
|
+
title: 'Boards',
|
|
47
|
+
links: [
|
|
48
|
+
{ id: 'daily', title: 'Daily', to: '/dispatch/daily' },
|
|
49
|
+
{ id: 'weekly', title: 'Weekly', to: '/dispatch/weekly' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
it('matches a sub-item page even though the row now navigates to a different sub-item', () => {
|
|
56
|
+
const isActive = getModuleActiveMatcher('', submenu)!;
|
|
57
|
+
expect(isActive('/dispatch/weekly')).toBe(true);
|
|
58
|
+
expect(isActive('/dispatch/daily')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('also matches the module own to, when it has one, alongside its sub-items', () => {
|
|
62
|
+
const isActive = getModuleActiveMatcher('/dispatch', submenu)!;
|
|
63
|
+
expect(isActive('/dispatch/routes')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not match an unrelated page', () => {
|
|
67
|
+
const isActive = getModuleActiveMatcher('', submenu)!;
|
|
68
|
+
expect(isActive('/accounting/invoices')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns undefined when there is nothing to match against', () => {
|
|
72
|
+
expect(getModuleActiveMatcher('', undefined)).toBeUndefined();
|
|
73
|
+
expect(
|
|
74
|
+
getModuleActiveMatcher('', { groups: [{ title: 'Empty', links: [] }] })
|
|
75
|
+
).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NavigationCatalogGroup } from '../components/titan-layout/interface';
|
|
2
|
+
import { NavigationItemData } from './navigation';
|
|
3
|
+
|
|
4
|
+
/** Preserve submenu headings while offering each page only once within its module. */
|
|
5
|
+
export const getCatalogSubmenuGroups = (item: NavigationItemData) => {
|
|
6
|
+
const seen = new Set<string>();
|
|
7
|
+
|
|
8
|
+
return (item.submenu?.groups ?? []).flatMap(group => {
|
|
9
|
+
const links = (group.links ?? []).filter(link => {
|
|
10
|
+
if (seen.has(link.id)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
seen.add(link.id);
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return links.length ? [{ ...group, title: group.title?.trim() ?? '', links }] : [];
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const getCatalogChildren = (item: NavigationItemData) =>
|
|
23
|
+
getCatalogSubmenuGroups(item).flatMap(group => group.links);
|
|
24
|
+
|
|
25
|
+
/** Match destination titles literally, retaining the headings of matching child pages. */
|
|
26
|
+
export const filterCatalogGroups = (groups: NavigationCatalogGroup[], query: string) => {
|
|
27
|
+
const needle = query.trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
if (!needle) {
|
|
30
|
+
return groups;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return groups.reduce<NavigationCatalogGroup[]>((out, group) => {
|
|
34
|
+
const items = group.items.reduce<NavigationItemData[]>((kept, item) => {
|
|
35
|
+
const matchingGroups = getCatalogSubmenuGroups(item).flatMap(submenuGroup => {
|
|
36
|
+
const links = submenuGroup.links.filter(child =>
|
|
37
|
+
child.title.toLowerCase().includes(needle)
|
|
38
|
+
);
|
|
39
|
+
return links.length ? [{ ...submenuGroup, links }] : [];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (matchingGroups.length > 0) {
|
|
43
|
+
kept.push({ ...item, submenu: { groups: matchingGroups } });
|
|
44
|
+
} else if (item.title.toLowerCase().includes(needle)) {
|
|
45
|
+
kept.push({ ...item, submenu: undefined });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return kept;
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
if (items.length > 0) {
|
|
52
|
+
out.push({ ...group, items });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return out;
|
|
56
|
+
}, []);
|
|
57
|
+
};
|
package/src/utils/side-nav.ts
CHANGED
|
@@ -2,6 +2,48 @@ import { CounterTagProps } from '../components/counter-tag';
|
|
|
2
2
|
import { CounterTagData, CounterTagValue } from './counter-tag';
|
|
3
3
|
import { NavigationSubmenuData } from './navigation';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A module with sub-items always routes to the first one, in the nav's existing order, rather
|
|
7
|
+
* than whatever destination the host configured for the module row itself (3.5a). A host-supplied
|
|
8
|
+
* `to` only applies to modules with no submenu.
|
|
9
|
+
*/
|
|
10
|
+
export function getModuleDestination(
|
|
11
|
+
to: string,
|
|
12
|
+
submenu: NavigationSubmenuData | undefined
|
|
13
|
+
): string {
|
|
14
|
+
for (const group of submenu?.groups ?? []) {
|
|
15
|
+
if (group.links.length > 0) {
|
|
16
|
+
return group.links[0].to;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return to;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Routing a module row to its first sub-item (see `getModuleDestination`) means the row's `to` no
|
|
25
|
+
* longer identifies the module for a host that has no explicit `isActive` of its own — the default
|
|
26
|
+
* "compare href to pathname" behavior would then only match the first sub-item's page, not the rest
|
|
27
|
+
* of the module. Synthesize an equivalent match across the module's own `to` and every sub-item
|
|
28
|
+
* instead, so the row still highlights on any of the module's pages, not just the first.
|
|
29
|
+
*
|
|
30
|
+
* Returns `undefined` when there's nothing to match, so a host can still supply its own `isActive`
|
|
31
|
+
* and take precedence (this only fills the gap left by the default behavior, it doesn't replace it).
|
|
32
|
+
*/
|
|
33
|
+
export function getModuleActiveMatcher(
|
|
34
|
+
to: string,
|
|
35
|
+
submenu: NavigationSubmenuData | undefined
|
|
36
|
+
): ((pathname: string) => boolean) | undefined {
|
|
37
|
+
const candidates = [
|
|
38
|
+
...(to ? [to] : []),
|
|
39
|
+
...(submenu?.groups.flatMap(group => group.links.map(link => link.to)) ?? []),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
return candidates.length > 0
|
|
43
|
+
? (pathname: string) => candidates.some(candidate => pathname.startsWith(candidate))
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
5
47
|
export function getSubmenuGroupTag(
|
|
6
48
|
submenu: NavigationSubmenuData | undefined,
|
|
7
49
|
defaultTag: CounterTagProps | undefined
|