@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.
- 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 +54 -63
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js.map +1 -1
- 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/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 +59 -7
- 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.tsx +58 -74
- package/src/utils/__tests__/side-nav.test.ts +77 -0
- package/src/utils/side-nav.ts +42 -0
|
@@ -30,7 +30,7 @@ const LockedPin: FC = () => (
|
|
|
30
30
|
);
|
|
31
31
|
|
|
32
32
|
interface PinToggleProps {
|
|
33
|
-
/**
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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
|
|
105
|
-
const
|
|
106
|
-
const
|
|
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
|
-
*
|
|
133
|
-
*
|
|
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={
|
|
136
|
+
pinned={isFullyPinned ? true : isOnRail ? 'mixed' : false}
|
|
144
137
|
label={
|
|
145
|
-
|
|
138
|
+
isOnRail
|
|
146
139
|
? `Unpin ${item.title} from the navigation`
|
|
147
140
|
: `Pin ${item.title} to the navigation`
|
|
148
141
|
}
|
|
149
|
-
onToggle={() =>
|
|
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={() =>
|
|
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
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
const
|
|
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(
|
|
280
|
+
onPinnedIdsChange(
|
|
281
|
+
scope.some(id => pinnedSet.has(id)) ? withoutModule : [...withoutModule, moduleId]
|
|
282
|
+
);
|
|
308
283
|
};
|
|
309
284
|
|
|
310
|
-
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
+
});
|
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
|