@servicetitan/navigation 14.3.0 → 14.4.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/navigation-catalog/navigation-catalog.d.ts.map +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +68 -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/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/package.json +2 -2
- package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +7 -3
- 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 +106 -134
- package/src/utils/__tests__/catalog-groups.test.ts +97 -0
- package/src/utils/catalog-groups.ts +57 -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,19 @@ 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}
|
|
66
57
|
onClick={toggle}
|
|
67
58
|
/>
|
|
68
59
|
</Tooltip.Trigger>
|
|
69
|
-
<Tooltip.Content>
|
|
70
|
-
{pinned
|
|
60
|
+
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
61
|
+
{pinned === 'mixed'
|
|
62
|
+
? 'Add this module to Favorites'
|
|
63
|
+
: pinned
|
|
64
|
+
? 'Remove from Favorites'
|
|
65
|
+
: 'Add to Favorites'}
|
|
71
66
|
</Tooltip.Content>
|
|
72
67
|
</Tooltip>
|
|
73
68
|
);
|
|
@@ -100,7 +95,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
100
95
|
onNavigate,
|
|
101
96
|
}) => {
|
|
102
97
|
const { NavigationComponent } = useTitanLayoutContext();
|
|
103
|
-
const
|
|
98
|
+
const submenuGroups = getCatalogSubmenuGroups(item);
|
|
104
99
|
|
|
105
100
|
/*
|
|
106
101
|
* Minus anything locked — locked destinations are always present, so the module's control
|
|
@@ -108,6 +103,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
108
103
|
*/
|
|
109
104
|
const everything = scopeIds.filter(id => !isLocked(id));
|
|
110
105
|
const anyPinned = everything.some(isPinned);
|
|
106
|
+
const allPinned = everything.length > 0 && everything.every(isPinned);
|
|
111
107
|
const tag = getCounterTag(item.counter, item.tag);
|
|
112
108
|
|
|
113
109
|
return (
|
|
@@ -120,9 +116,9 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
120
116
|
aria-label={item.title}
|
|
121
117
|
>
|
|
122
118
|
{item.icon && (
|
|
123
|
-
<Icon svg={item.icon} size="
|
|
119
|
+
<Icon svg={item.icon} size="medium" className={styles.moduleIcon} />
|
|
124
120
|
)}
|
|
125
|
-
<Text inline className={styles.moduleTitle}>
|
|
121
|
+
<Text inline size="small" className={styles.moduleTitle}>
|
|
126
122
|
{item.title}
|
|
127
123
|
</Text>
|
|
128
124
|
{/*
|
|
@@ -136,110 +132,80 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
136
132
|
* One control for the whole module: favouriting it takes its pages with it, and
|
|
137
133
|
* unfavouriting it takes them away again.
|
|
138
134
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* star on a module the user can plainly see in the navigation.
|
|
135
|
+
* A gold outline marks a partially favourited module. Fill the star only when
|
|
136
|
+
* its full, unfiltered scope is favourited, including pages hidden by search.
|
|
137
|
+
* Clicking the outline adds the remaining pages; clicking the filled star removes all.
|
|
143
138
|
*/}
|
|
144
139
|
{isLocked(item.id) ? (
|
|
145
140
|
<LockedPin />
|
|
146
141
|
) : (
|
|
147
142
|
<PinToggle
|
|
148
|
-
pinned={anyPinned}
|
|
143
|
+
pinned={allPinned ? true : anyPinned ? 'mixed' : false}
|
|
149
144
|
label={
|
|
150
|
-
|
|
145
|
+
allPinned
|
|
151
146
|
? `Unpin ${item.title} from the navigation`
|
|
152
147
|
: `Pin ${item.title} to the navigation`
|
|
153
148
|
}
|
|
154
|
-
onToggle={() => onToggleAll(everything, !
|
|
149
|
+
onToggle={() => onToggleAll(everything, !allPinned)}
|
|
155
150
|
/>
|
|
156
151
|
)}
|
|
157
152
|
</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
|
-
|
|
153
|
+
{submenuGroups.map(group => (
|
|
154
|
+
<div key={group.links[0].id}>
|
|
155
|
+
{group.title && (
|
|
156
|
+
<Text variant="eyebrow" size="small" className={styles.submenuTitle}>
|
|
157
|
+
{group.title}
|
|
158
|
+
</Text>
|
|
159
|
+
)}
|
|
160
|
+
{group.links.map(child => {
|
|
161
|
+
/*
|
|
162
|
+
* Page titles are only unique within their item — "Settings" and "Reports" recur
|
|
163
|
+
* across the catalog — and the row that tells them apart is a separate element, so
|
|
164
|
+
* it is not part of any of these names. Anything reading the rows on their own, a
|
|
165
|
+
* screen reader running through the links or a voice command naming one, would
|
|
166
|
+
* otherwise be given the same name several times.
|
|
167
|
+
*/
|
|
168
|
+
const qualifiedTitle = `${item.title}: ${child.title}`;
|
|
169
|
+
const childPinned = isPinned(child.id);
|
|
170
|
+
const childTag = getCounterTag(child.counter, child.tag);
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div key={child.id} className={styles.row}>
|
|
174
|
+
<NavigationComponent
|
|
175
|
+
to={child.to}
|
|
176
|
+
className={classNames(styles.link, styles.childLink)}
|
|
177
|
+
onClick={onNavigate}
|
|
178
|
+
aria-label={qualifiedTitle}
|
|
179
|
+
>
|
|
180
|
+
<Text inline className={styles.childTitle}>
|
|
181
|
+
{child.title}
|
|
182
|
+
</Text>
|
|
183
|
+
{!!childTag && (
|
|
184
|
+
<BadgeTag data={childTag} className={styles.badge} />
|
|
185
|
+
)}
|
|
186
|
+
</NavigationComponent>
|
|
187
|
+
{isLocked(child.id) ? (
|
|
188
|
+
<LockedPin />
|
|
189
|
+
) : (
|
|
190
|
+
<PinToggle
|
|
191
|
+
pinned={childPinned}
|
|
192
|
+
label={
|
|
193
|
+
childPinned
|
|
194
|
+
? `Unpin ${qualifiedTitle} from the navigation`
|
|
195
|
+
: `Pin ${qualifiedTitle} to the navigation`
|
|
196
|
+
}
|
|
197
|
+
onToggle={() => onTogglePin(child.id, item.id)}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
199
205
|
</div>
|
|
200
206
|
);
|
|
201
207
|
};
|
|
202
208
|
|
|
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
209
|
export interface NavigationCatalogProps extends PinnedNavigationConfig {
|
|
244
210
|
open: boolean;
|
|
245
211
|
onClose: () => void;
|
|
@@ -261,6 +227,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
261
227
|
onResetToDefaults,
|
|
262
228
|
}) => {
|
|
263
229
|
const [query, setQuery] = useState('');
|
|
230
|
+
const isSearching = query.trim().length > 0;
|
|
264
231
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
265
232
|
|
|
266
233
|
/*
|
|
@@ -271,7 +238,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
271
238
|
const resolvedGroups = useMemo(() => groups(), [groups]);
|
|
272
239
|
|
|
273
240
|
const visibleGroups = useMemo(
|
|
274
|
-
() =>
|
|
241
|
+
() => filterCatalogGroups(resolvedGroups, query),
|
|
275
242
|
[resolvedGroups, query]
|
|
276
243
|
);
|
|
277
244
|
|
|
@@ -284,7 +251,10 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
284
251
|
new Map(
|
|
285
252
|
resolvedGroups
|
|
286
253
|
.flatMap(group => group.items)
|
|
287
|
-
.map(item => [
|
|
254
|
+
.map(item => [
|
|
255
|
+
item.id,
|
|
256
|
+
[item.id, ...getCatalogChildren(item).map(child => child.id)],
|
|
257
|
+
])
|
|
288
258
|
),
|
|
289
259
|
[resolvedGroups]
|
|
290
260
|
);
|
|
@@ -385,7 +355,9 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
385
355
|
onClear={() => setQuery('')}
|
|
386
356
|
aria-label="Go to"
|
|
387
357
|
/>
|
|
388
|
-
<div
|
|
358
|
+
<div
|
|
359
|
+
className={classNames(styles.results, isSearching && styles.searchResults)}
|
|
360
|
+
>
|
|
389
361
|
{visibleGroups.length === 0 ? (
|
|
390
362
|
<div className={styles.empty}>
|
|
391
363
|
{/*
|
|
@@ -395,7 +367,7 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
395
367
|
* quoting an empty query at the user does not describe it.
|
|
396
368
|
*/}
|
|
397
369
|
<Text subdued>
|
|
398
|
-
{
|
|
370
|
+
{isSearching ? (
|
|
399
371
|
<Fragment>
|
|
400
372
|
No navigation items match “{query}”.
|
|
401
373
|
</Fragment>
|
|
@@ -405,25 +377,25 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
405
377
|
</Text>
|
|
406
378
|
</div>
|
|
407
379
|
) : (
|
|
408
|
-
<div className={styles.columns}>
|
|
380
|
+
<div className={isSearching ? undefined : styles.columns}>
|
|
409
381
|
{visibleGroups.map(group => (
|
|
410
|
-
<div
|
|
382
|
+
<div
|
|
383
|
+
key={group.title}
|
|
384
|
+
className={isSearching ? undefined : styles.group}
|
|
385
|
+
>
|
|
411
386
|
{/*
|
|
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.
|
|
387
|
+
* Browse uses titled columns. Search stacks matches at full
|
|
388
|
+
* width, keeping only each module's own submenu headings.
|
|
419
389
|
*/}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
390
|
+
{!isSearching && (
|
|
391
|
+
<Text
|
|
392
|
+
variant="eyebrow"
|
|
393
|
+
size="medium"
|
|
394
|
+
className={styles.groupTitle}
|
|
395
|
+
>
|
|
396
|
+
{group.title}
|
|
397
|
+
</Text>
|
|
398
|
+
)}
|
|
427
399
|
{group.items.map(item => (
|
|
428
400
|
<CatalogRows
|
|
429
401
|
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,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
|
+
};
|