@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.
@@ -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 { NavigationCatalogGroup, PinnedNavigationConfig } from '../interface';
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>Cannot be removed from Favorites</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: boolean;
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 ? 'Remove from Favorites' : 'Add to Favorites'}
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 children = childrenOf(item);
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="small" className={styles.moduleIcon} />
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
- * Lit when *any* page under it is favourited, because that is exactly when the
140
- * module is on the rail — the star reports whether the module is there, not
141
- * whether every page under it is. Reporting "all" instead would leave an unlit
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
- anyPinned
145
+ allPinned
151
146
  ? `Unpin ${item.title} from the navigation`
152
147
  : `Pin ${item.title} to the navigation`
153
148
  }
154
- onToggle={() => onToggleAll(everything, !anyPinned)}
149
+ onToggle={() => onToggleAll(everything, !allPinned)}
155
150
  />
156
151
  )}
157
152
  </div>
158
- {children.map(child => {
159
- /*
160
- * Page titles are only unique within their item — "Settings" and "Reports" recur
161
- * across the catalog — and the row that tells them apart is a separate element, so
162
- * it is not part of any of these names. Anything reading the rows on their own, a
163
- * screen reader running through the links or a voice command naming one, would
164
- * otherwise be given the same name several times.
165
- */
166
- const qualifiedTitle = `${item.title}: ${child.title}`;
167
- const childPinned = isPinned(child.id);
168
- const childTag = getCounterTag(child.counter, child.tag);
169
-
170
- return (
171
- <div key={child.id} className={styles.row}>
172
- <NavigationComponent
173
- to={child.to}
174
- className={classNames(styles.link, styles.childLink)}
175
- onClick={onNavigate}
176
- aria-label={qualifiedTitle}
177
- >
178
- <Text inline className={styles.childTitle}>
179
- {child.title}
180
- </Text>
181
- {!!childTag && <BadgeTag data={childTag} className={styles.badge} />}
182
- </NavigationComponent>
183
- {isLocked(child.id) ? (
184
- <LockedPin />
185
- ) : (
186
- <PinToggle
187
- pinned={childPinned}
188
- label={
189
- childPinned
190
- ? `Unpin ${qualifiedTitle} from the navigation`
191
- : `Pin ${qualifiedTitle} to the navigation`
192
- }
193
- onToggle={() => onTogglePin(child.id, item.id)}
194
- />
195
- )}
196
- </div>
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
- () => filterGroups(resolvedGroups, query),
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 => [item.id, [item.id, ...childrenOf(item).map(child => child.id)]])
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 className={styles.results}>
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
- {query.trim() ? (
370
+ {isSearching ? (
399
371
  <Fragment>
400
372
  No navigation items match &ldquo;{query}&rdquo;.
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 key={group.title} className={styles.group}>
382
+ <div
383
+ key={group.title}
384
+ className={isSearching ? undefined : styles.group}
385
+ >
411
386
  {/*
412
- * `medium` is 0.75rem, a step below the destinations under
413
- * it. A heading that matches or beats its contents in size
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
- <Text
421
- variant="eyebrow"
422
- size="medium"
423
- className={styles.groupTitle}
424
- >
425
- {group.title}
426
- </Text>
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
+ };