@servicetitan/navigation 14.4.0 → 14.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.
Files changed (23) hide show
  1. package/dist/components/titan-layout/layout-sidebar.d.ts.map +1 -1
  2. package/dist/components/titan-layout/layout-sidebar.js +30 -11
  3. package/dist/components/titan-layout/layout-sidebar.js.map +1 -1
  4. package/dist/components/titan-layout/layout-sidebar.module.less +1 -1
  5. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js +1 -1
  6. package/dist/components/titan-layout/navigation-catalog/navigation-catalog-trigger.js.map +1 -1
  7. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.d.ts.map +1 -1
  8. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +54 -63
  9. package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -1
  10. package/dist/utils/__tests__/side-nav.test.d.ts +2 -0
  11. package/dist/utils/__tests__/side-nav.test.d.ts.map +1 -0
  12. package/dist/utils/side-nav.d.ts +17 -0
  13. package/dist/utils/side-nav.d.ts.map +1 -1
  14. package/dist/utils/side-nav.js +32 -0
  15. package/dist/utils/side-nav.js.map +1 -1
  16. package/package.json +2 -2
  17. package/src/components/titan-layout/__tests__/navigation-catalog-reset.test.tsx +59 -7
  18. package/src/components/titan-layout/layout-sidebar.module.less +1 -1
  19. package/src/components/titan-layout/layout-sidebar.tsx +34 -10
  20. package/src/components/titan-layout/navigation-catalog/navigation-catalog-trigger.tsx +1 -1
  21. package/src/components/titan-layout/navigation-catalog/navigation-catalog.tsx +58 -74
  22. package/src/utils/__tests__/side-nav.test.ts +77 -0
  23. package/src/utils/side-nav.ts +42 -0
@@ -30,7 +30,7 @@ const LockedPin: FC = () => (
30
30
  );
31
31
 
32
32
  interface PinToggleProps {
33
- /** Mixed modules stay highlighted but use an outline until all pages are pinned. */
33
+ /** `mixed` is a module on the rail with only some of its pages: an outline, still active. */
34
34
  pinned: boolean | 'mixed';
35
35
  label: string;
36
36
  onToggle: () => void;
@@ -54,15 +54,12 @@ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
54
54
  className={classNames(styles.star, pinned && styles.starActive)}
55
55
  aria-label={label}
56
56
  aria-pressed={pinned}
57
+ data-pendo="global-nav-favorite-star"
57
58
  onClick={toggle}
58
59
  />
59
60
  </Tooltip.Trigger>
60
61
  <Tooltip.Content style={{ pointerEvents: 'none' }}>
61
- {pinned === 'mixed'
62
- ? 'Add this module to Favorites'
63
- : pinned
64
- ? 'Remove from Favorites'
65
- : 'Add to Favorites'}
62
+ {pinned ? 'Remove from Favorites' : 'Add to Favorites'}
66
63
  </Tooltip.Content>
67
64
  </Tooltip>
68
65
  );
@@ -79,9 +76,8 @@ interface CatalogRowsProps {
79
76
  scopeIds: string[];
80
77
  isPinned: (id: string) => boolean;
81
78
  isLocked: (id: string) => boolean;
82
- /** `ownerId` is the module the page belongs to; omitted for the module's own row. */
83
- onTogglePin: (id: string, ownerId?: string) => void;
84
- onToggleAll: (ids: string[], shouldPin: boolean) => void;
79
+ onToggleModule: (moduleId: string) => void;
80
+ onTogglePage: (id: string, moduleId: string) => void;
85
81
  onNavigate: () => void;
86
82
  }
87
83
 
@@ -90,20 +86,21 @@ const CatalogRows: FC<CatalogRowsProps> = ({
90
86
  scopeIds,
91
87
  isPinned,
92
88
  isLocked,
93
- onTogglePin,
94
- onToggleAll,
89
+ onToggleModule,
90
+ onTogglePage,
95
91
  onNavigate,
96
92
  }) => {
97
93
  const { NavigationComponent } = useTitanLayoutContext();
98
94
  const submenuGroups = getCatalogSubmenuGroups(item);
99
95
 
100
96
  /*
101
- * Minus anything locked — locked destinations are always present, so the module's control
102
- * neither reports on them nor acts on them.
97
+ * A pinned module id means the whole module, every page included. Pinned page ids without
98
+ * their module mean the module is on the rail carrying only those pages.
103
99
  */
104
- const everything = scopeIds.filter(id => !isLocked(id));
105
- const anyPinned = everything.some(isPinned);
106
- const allPinned = everything.length > 0 && everything.every(isPinned);
100
+ const isWholeModule = isPinned(item.id);
101
+ const isOnRail = scopeIds.some(isPinned);
102
+ const pageIds = scopeIds.filter(id => id !== item.id && !isLocked(id));
103
+ const isFullyPinned = isWholeModule || (pageIds.length > 0 && pageIds.every(isPinned));
107
104
  const tag = getCounterTag(item.counter, item.tag);
108
105
 
109
106
  return (
@@ -129,24 +126,20 @@ const CatalogRows: FC<CatalogRowsProps> = ({
129
126
  {!!tag && <BadgeTag data={tag} className={styles.badge} />}
130
127
  </NavigationComponent>
131
128
  {/*
132
- * One control for the whole module: favouriting it takes its pages with it, and
133
- * unfavouriting it takes them away again.
134
- *
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.
129
+ * Filled when every page is favourited, an outline when only some are. Either way
130
+ * the module is on the rail, so one click takes it off with all of its pages.
138
131
  */}
139
132
  {isLocked(item.id) ? (
140
133
  <LockedPin />
141
134
  ) : (
142
135
  <PinToggle
143
- pinned={allPinned ? true : anyPinned ? 'mixed' : false}
136
+ pinned={isFullyPinned ? true : isOnRail ? 'mixed' : false}
144
137
  label={
145
- allPinned
138
+ isOnRail
146
139
  ? `Unpin ${item.title} from the navigation`
147
140
  : `Pin ${item.title} to the navigation`
148
141
  }
149
- onToggle={() => onToggleAll(everything, !allPinned)}
142
+ onToggle={() => onToggleModule(item.id)}
150
143
  />
151
144
  )}
152
145
  </div>
@@ -166,7 +159,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
166
159
  * otherwise be given the same name several times.
167
160
  */
168
161
  const qualifiedTitle = `${item.title}: ${child.title}`;
169
- const childPinned = isPinned(child.id);
162
+ const childPinned = isWholeModule || isPinned(child.id);
170
163
  const childTag = getCounterTag(child.counter, child.tag);
171
164
 
172
165
  return (
@@ -194,7 +187,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
194
187
  ? `Unpin ${qualifiedTitle} from the navigation`
195
188
  : `Pin ${qualifiedTitle} to the navigation`
196
189
  }
197
- onToggle={() => onTogglePin(child.id, item.id)}
190
+ onToggle={() => onTogglePage(child.id, item.id)}
198
191
  />
199
192
  )}
200
193
  </div>
@@ -264,69 +257,60 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
264
257
  const isPinned = (id: string) => pinnedSet.has(id);
265
258
  const isLocked = (id: string) => lockedSet.has(id);
266
259
 
267
- /**
268
- * Favourites or unfavourites one row, carrying its module with it.
269
- *
270
- * A page is reached through its module, so the two travel together: favouriting a page brings
271
- * the module along when it is not already there, and unfavouriting the last one takes the
272
- * module with it. Leaving the module behind on its own would not read as an emptier
273
- * navigation — it would put the module back carrying every page it has.
260
+ /*
261
+ * The saved list follows the host's model: a module id pins the whole module, and page ids
262
+ * without their module pin the module carrying only those pages. A module id is therefore
263
+ * never saved next to its own page ids — the host would read that as the whole module.
274
264
  *
275
265
  * Locked ids never reach a proposal. The controls that would move them are not rendered, so
276
266
  * that part is belt and braces — but the host's list is the thing that gets persisted, and a
277
267
  * locked destination has no business appearing in or disappearing from it.
278
268
  */
279
- const togglePin = (id: string, ownerId?: string) => {
280
- if (lockedSet.has(id)) {
281
- return;
282
- }
283
-
284
- /** The module, when it is a separate row that this page can legitimately move. */
285
- const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
286
-
287
- if (pinnedSet.has(id)) {
288
- const removing = new Set([id]);
289
-
290
- if (owner) {
291
- const siblings = (scopeById.get(owner) ?? []).filter(
292
- scoped => scoped !== owner && scoped !== id
293
- );
294
-
295
- if (!siblings.some(sibling => pinnedSet.has(sibling))) {
296
- removing.add(owner);
297
- }
298
- }
299
269
 
300
- onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
270
+ /** Takes a module off the rail with all of its pages, or puts the whole module on it. */
271
+ const toggleModule = (moduleId: string) => {
272
+ if (lockedSet.has(moduleId)) {
301
273
  return;
302
274
  }
303
275
 
304
- // Module first, so it reads ahead of its own page in the saved order.
305
- const additions = owner && !pinnedSet.has(owner) ? [owner, id] : [id];
276
+ const scope = (scopeById.get(moduleId) ?? [moduleId]).filter(id => !lockedSet.has(id));
277
+ const scopeSet = new Set(scope);
278
+ const withoutModule = pinnedIds.filter(pinned => !scopeSet.has(pinned));
306
279
 
307
- onPinnedIdsChange([...pinnedIds, ...additions]);
280
+ onPinnedIdsChange(
281
+ scope.some(id => pinnedSet.has(id)) ? withoutModule : [...withoutModule, moduleId]
282
+ );
308
283
  };
309
284
 
310
- /*
311
- * One proposal for the whole set rather than one per row, so a host that persists on every
312
- * change writes once, and every intermediate list — states the user never asked for — never
313
- * exists.
285
+ /**
286
+ * Favourites or unfavourites a single page.
287
+ *
288
+ * Unstarring a page of a wholly favourited module narrows the module to the rest of its pages,
289
+ * in the module's place; unstarring its last page takes the module off the rail.
314
290
  */
315
- const toggleAll = (ids: string[], shouldPin: boolean) => {
316
- const actionable = ids.filter(id => !lockedSet.has(id));
317
-
318
- if (!shouldPin) {
319
- const removing = new Set(actionable);
320
-
321
- onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
291
+ const togglePage = (id: string, moduleId: string) => {
292
+ if (lockedSet.has(id)) {
322
293
  return;
323
294
  }
324
295
 
325
- const additions = actionable.filter(id => !pinnedSet.has(id));
296
+ const scope = scopeById.get(moduleId) ?? [moduleId];
297
+ const pages = scope.filter(scoped => scoped !== moduleId && !lockedSet.has(scoped));
298
+
299
+ if (pinnedSet.has(moduleId) && !lockedSet.has(moduleId)) {
300
+ const scopeSet = new Set(scope);
301
+ const rest = pages.filter(page => page !== id);
326
302
 
327
- if (additions.length > 0) {
328
- onPinnedIdsChange([...pinnedIds, ...additions]);
303
+ onPinnedIdsChange(
304
+ pinnedIds.flatMap(pinned =>
305
+ pinned === moduleId ? rest : scopeSet.has(pinned) ? [] : [pinned]
306
+ )
307
+ );
308
+ return;
329
309
  }
310
+
311
+ onPinnedIdsChange(
312
+ pinnedSet.has(id) ? pinnedIds.filter(pinned => pinned !== id) : [...pinnedIds, id]
313
+ );
330
314
  };
331
315
 
332
316
  const close = () => {
@@ -403,8 +387,8 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
403
387
  scopeIds={scopeById.get(item.id) ?? [item.id]}
404
388
  isPinned={isPinned}
405
389
  isLocked={isLocked}
406
- onTogglePin={togglePin}
407
- onToggleAll={toggleAll}
390
+ onToggleModule={toggleModule}
391
+ onTogglePage={togglePage}
408
392
  onNavigate={close}
409
393
  />
410
394
  ))}
@@ -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
+ });
@@ -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