@servicetitan/navigation 14.5.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/navigation-catalog/navigation-catalog.d.ts.map +1 -1
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.js +53 -63
- package/dist/components/titan-layout/navigation-catalog/navigation-catalog.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/navigation-catalog/navigation-catalog.tsx +57 -74
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-catalog.d.ts","sourceRoot":"","sources":["../../../../src/components/titan-layout/navigation-catalog/navigation-catalog.tsx"],"names":[],"mappings":"AAKA,OAAO,EAAE,EAAE,EAAmD,MAAM,OAAO,CAAC;AAS5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"navigation-catalog.d.ts","sourceRoot":"","sources":["../../../../src/components/titan-layout/navigation-catalog/navigation-catalog.tsx"],"names":[],"mappings":"AAKA,OAAO,EAAE,EAAE,EAAmD,MAAM,OAAO,CAAC;AAS5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AA2LtD,MAAM,WAAW,sBAAuB,SAAQ,sBAAsB;IAClE,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;CACvB;AAED;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,EAAE,CAAC,sBAAsB,CAyMxD,CAAC"}
|
|
@@ -53,20 +53,21 @@ const PinToggle = ({ pinned, label, onToggle })=>{
|
|
|
53
53
|
style: {
|
|
54
54
|
pointerEvents: 'none'
|
|
55
55
|
},
|
|
56
|
-
children: pinned
|
|
56
|
+
children: pinned ? 'Remove from Favorites' : 'Add to Favorites'
|
|
57
57
|
})
|
|
58
58
|
]
|
|
59
59
|
});
|
|
60
60
|
};
|
|
61
|
-
const CatalogRows = ({ item, scopeIds, isPinned, isLocked,
|
|
61
|
+
const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onToggleModule, onTogglePage, onNavigate })=>{
|
|
62
62
|
const { NavigationComponent } = useTitanLayoutContext();
|
|
63
63
|
const submenuGroups = getCatalogSubmenuGroups(item);
|
|
64
64
|
/*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*/ const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
65
|
+
* A pinned module id means the whole module, every page included. Pinned page ids without
|
|
66
|
+
* their module mean the module is on the rail carrying only those pages.
|
|
67
|
+
*/ const isWholeModule = isPinned(item.id);
|
|
68
|
+
const isOnRail = scopeIds.some(isPinned);
|
|
69
|
+
const pageIds = scopeIds.filter((id)=>id !== item.id && !isLocked(id));
|
|
70
|
+
const isFullyPinned = isWholeModule || pageIds.length > 0 && pageIds.every(isPinned);
|
|
70
71
|
const tag = getCounterTag(item.counter, item.tag);
|
|
71
72
|
return /*#__PURE__*/ _jsxs("div", {
|
|
72
73
|
className: styles.moduleGroup,
|
|
@@ -98,9 +99,9 @@ const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onTogglePin, onToggle
|
|
|
98
99
|
]
|
|
99
100
|
}),
|
|
100
101
|
isLocked(item.id) ? /*#__PURE__*/ _jsx(LockedPin, {}) : /*#__PURE__*/ _jsx(PinToggle, {
|
|
101
|
-
pinned:
|
|
102
|
-
label:
|
|
103
|
-
onToggle: ()=>
|
|
102
|
+
pinned: isFullyPinned ? true : isOnRail ? 'mixed' : false,
|
|
103
|
+
label: isOnRail ? `Unpin ${item.title} from the navigation` : `Pin ${item.title} to the navigation`,
|
|
104
|
+
onToggle: ()=>onToggleModule(item.id)
|
|
104
105
|
})
|
|
105
106
|
]
|
|
106
107
|
}),
|
|
@@ -120,7 +121,7 @@ const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onTogglePin, onToggle
|
|
|
120
121
|
* screen reader running through the links or a voice command naming one, would
|
|
121
122
|
* otherwise be given the same name several times.
|
|
122
123
|
*/ const qualifiedTitle = `${item.title}: ${child.title}`;
|
|
123
|
-
const childPinned = isPinned(child.id);
|
|
124
|
+
const childPinned = isWholeModule || isPinned(child.id);
|
|
124
125
|
const childTag = getCounterTag(child.counter, child.tag);
|
|
125
126
|
return /*#__PURE__*/ _jsxs("div", {
|
|
126
127
|
className: styles.row,
|
|
@@ -145,7 +146,7 @@ const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onTogglePin, onToggle
|
|
|
145
146
|
isLocked(child.id) ? /*#__PURE__*/ _jsx(LockedPin, {}) : /*#__PURE__*/ _jsx(PinToggle, {
|
|
146
147
|
pinned: childPinned,
|
|
147
148
|
label: childPinned ? `Unpin ${qualifiedTitle} from the navigation` : `Pin ${qualifiedTitle} to the navigation`,
|
|
148
|
-
onToggle: ()=>
|
|
149
|
+
onToggle: ()=>onTogglePage(child.id, item.id)
|
|
149
150
|
})
|
|
150
151
|
]
|
|
151
152
|
}, child.id);
|
|
@@ -195,66 +196,55 @@ const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onTogglePin, onToggle
|
|
|
195
196
|
]);
|
|
196
197
|
const isPinned = (id)=>pinnedSet.has(id);
|
|
197
198
|
const isLocked = (id)=>lockedSet.has(id);
|
|
198
|
-
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* the module along when it is not already there, and unfavouriting the last one takes the
|
|
203
|
-
* module with it. Leaving the module behind on its own would not read as an emptier
|
|
204
|
-
* navigation — it would put the module back carrying every page it has.
|
|
199
|
+
/*
|
|
200
|
+
* The saved list follows the host's model: a module id pins the whole module, and page ids
|
|
201
|
+
* without their module pin the module carrying only those pages. A module id is therefore
|
|
202
|
+
* never saved next to its own page ids — the host would read that as the whole module.
|
|
205
203
|
*
|
|
206
204
|
* Locked ids never reach a proposal. The controls that would move them are not rendered, so
|
|
207
205
|
* that part is belt and braces — but the host's list is the thing that gets persisted, and a
|
|
208
206
|
* locked destination has no business appearing in or disappearing from it.
|
|
209
|
-
*/ const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
/** The module, when it is a separate row that this page can legitimately move. */ const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
|
|
214
|
-
if (pinnedSet.has(id)) {
|
|
215
|
-
const removing = new Set([
|
|
216
|
-
id
|
|
217
|
-
]);
|
|
218
|
-
if (owner) {
|
|
219
|
-
var _scopeById_get;
|
|
220
|
-
const siblings = ((_scopeById_get = scopeById.get(owner)) !== null && _scopeById_get !== void 0 ? _scopeById_get : []).filter((scoped)=>scoped !== owner && scoped !== id);
|
|
221
|
-
if (!siblings.some((sibling)=>pinnedSet.has(sibling))) {
|
|
222
|
-
removing.add(owner);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
onPinnedIdsChange(pinnedIds.filter((pinned)=>!removing.has(pinned)));
|
|
207
|
+
*/ /** Takes a module off the rail with all of its pages, or puts the whole module on it. */ const toggleModule = (moduleId)=>{
|
|
208
|
+
var _scopeById_get;
|
|
209
|
+
if (lockedSet.has(moduleId)) {
|
|
226
210
|
return;
|
|
227
211
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
...pinnedIds,
|
|
237
|
-
...additions
|
|
212
|
+
const scope = ((_scopeById_get = scopeById.get(moduleId)) !== null && _scopeById_get !== void 0 ? _scopeById_get : [
|
|
213
|
+
moduleId
|
|
214
|
+
]).filter((id)=>!lockedSet.has(id));
|
|
215
|
+
const scopeSet = new Set(scope);
|
|
216
|
+
const withoutModule = pinnedIds.filter((pinned)=>!scopeSet.has(pinned));
|
|
217
|
+
onPinnedIdsChange(scope.some((id)=>pinnedSet.has(id)) ? withoutModule : [
|
|
218
|
+
...withoutModule,
|
|
219
|
+
moduleId
|
|
238
220
|
]);
|
|
239
221
|
};
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
onPinnedIdsChange(pinnedIds.filter((pinned)=>!removing.has(pinned)));
|
|
222
|
+
/**
|
|
223
|
+
* Favourites or unfavourites a single page.
|
|
224
|
+
*
|
|
225
|
+
* Unstarring a page of a wholly favourited module narrows the module to the rest of its pages,
|
|
226
|
+
* in the module's place; unstarring its last page takes the module off the rail.
|
|
227
|
+
*/ const togglePage = (id, moduleId)=>{
|
|
228
|
+
var _scopeById_get;
|
|
229
|
+
if (lockedSet.has(id)) {
|
|
249
230
|
return;
|
|
250
231
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
232
|
+
const scope = (_scopeById_get = scopeById.get(moduleId)) !== null && _scopeById_get !== void 0 ? _scopeById_get : [
|
|
233
|
+
moduleId
|
|
234
|
+
];
|
|
235
|
+
const pages = scope.filter((scoped)=>scoped !== moduleId && !lockedSet.has(scoped));
|
|
236
|
+
if (pinnedSet.has(moduleId) && !lockedSet.has(moduleId)) {
|
|
237
|
+
const scopeSet = new Set(scope);
|
|
238
|
+
const rest = pages.filter((page)=>page !== id);
|
|
239
|
+
onPinnedIdsChange(pinnedIds.flatMap((pinned)=>pinned === moduleId ? rest : scopeSet.has(pinned) ? [] : [
|
|
240
|
+
pinned
|
|
241
|
+
]));
|
|
242
|
+
return;
|
|
257
243
|
}
|
|
244
|
+
onPinnedIdsChange(pinnedSet.has(id) ? pinnedIds.filter((pinned)=>pinned !== id) : [
|
|
245
|
+
...pinnedIds,
|
|
246
|
+
id
|
|
247
|
+
]);
|
|
258
248
|
};
|
|
259
249
|
const close = ()=>{
|
|
260
250
|
setQuery('');
|
|
@@ -317,8 +307,8 @@ const CatalogRows = ({ item, scopeIds, isPinned, isLocked, onTogglePin, onToggle
|
|
|
317
307
|
],
|
|
318
308
|
isPinned: isPinned,
|
|
319
309
|
isLocked: isLocked,
|
|
320
|
-
|
|
321
|
-
|
|
310
|
+
onToggleModule: toggleModule,
|
|
311
|
+
onTogglePage: togglePage,
|
|
322
312
|
onNavigate: close
|
|
323
313
|
}, item.id);
|
|
324
314
|
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/components/titan-layout/navigation-catalog/navigation-catalog.tsx"],"sourcesContent":["import { Button, Dialog, Icon, SearchField, Text, Tooltip } from '@servicetitan/anvil2';\nimport IconLock from '@servicetitan/anvil2/assets/icons/material/round/lock.svg';\nimport IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg';\nimport IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';\nimport classNames from 'classnames';\nimport { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';\nimport {\n filterCatalogGroups,\n getCatalogChildren,\n getCatalogSubmenuGroups,\n} from '../../../utils/catalog-groups';\nimport { NavigationItemData } from '../../../utils/navigation';\nimport { getCounterTag } from '../../../utils/side-nav';\nimport { BadgeTag } from '../../badge-tag';\nimport { PinnedNavigationConfig } from '../interface';\nimport { useTitanLayoutContext } from '../layout-context';\nimport { NavigationCatalogFooter } from './navigation-catalog-footer';\nimport * as styles from './navigation-catalog.module.less';\n\n/** Shown in place of the pin control for a destination the user may not unpin. */\nconst LockedPin: FC = () => (\n <Tooltip>\n <Tooltip.Trigger>\n <Icon svg={IconLock} className={styles.lock} />\n </Tooltip.Trigger>\n <Tooltip.Content style={{ pointerEvents: 'none' }}>\n Cannot be removed from Favorites\n </Tooltip.Content>\n </Tooltip>\n);\n\ninterface PinToggleProps {\n /** Mixed modules stay highlighted but use an outline until all pages are pinned. */\n pinned: boolean | 'mixed';\n label: string;\n onToggle: () => void;\n}\n\nconst PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {\n const toggle = (event: MouseEvent) => {\n // The control sits inside the row but is not a navigation affordance.\n event.preventDefault();\n event.stopPropagation();\n onToggle();\n };\n\n return (\n <Tooltip delay={300}>\n <Tooltip.Trigger>\n <Button\n appearance=\"ghost\"\n size=\"small\"\n icon={pinned === true ? IconStar : IconStarBorder}\n className={classNames(styles.star, pinned && styles.starActive)}\n aria-label={label}\n aria-pressed={pinned}\n data-pendo=\"global-nav-favorite-star\"\n onClick={toggle}\n />\n </Tooltip.Trigger>\n <Tooltip.Content style={{ pointerEvents: 'none' }}>\n {pinned === 'mixed'\n ? 'Add this module to Favorites'\n : pinned\n ? 'Remove from Favorites'\n : 'Add to Favorites'}\n </Tooltip.Content>\n </Tooltip>\n );\n};\n\ninterface CatalogRowsProps {\n item: NavigationItemData;\n /**\n * Every id the module's own control covers, taken from the unfiltered item.\n *\n * `item` may have been narrowed by the search, so deriving this from it would let the control\n * act on only the pages that happen to be visible — silently stranding the rest.\n */\n scopeIds: string[];\n isPinned: (id: string) => boolean;\n isLocked: (id: string) => boolean;\n /** `ownerId` is the module the page belongs to; omitted for the module's own row. */\n onTogglePin: (id: string, ownerId?: string) => void;\n onToggleAll: (ids: string[], shouldPin: boolean) => void;\n onNavigate: () => void;\n}\n\nconst CatalogRows: FC<CatalogRowsProps> = ({\n item,\n scopeIds,\n isPinned,\n isLocked,\n onTogglePin,\n onToggleAll,\n onNavigate,\n}) => {\n const { NavigationComponent } = useTitanLayoutContext();\n const submenuGroups = getCatalogSubmenuGroups(item);\n\n /*\n * Minus anything locked — locked destinations are always present, so the module's control\n * neither reports on them nor acts on them.\n */\n const everything = scopeIds.filter(id => !isLocked(id));\n const anyPinned = everything.some(isPinned);\n const allPinned = everything.length > 0 && everything.every(isPinned);\n const tag = getCounterTag(item.counter, item.tag);\n\n return (\n <div className={styles.moduleGroup}>\n <div className={styles.row}>\n <NavigationComponent\n to={item.to}\n className={styles.link}\n onClick={onNavigate}\n aria-label={item.title}\n >\n {item.icon && (\n <Icon svg={item.icon} size=\"medium\" className={styles.moduleIcon} />\n )}\n <Text inline size=\"small\" className={styles.moduleTitle}>\n {item.title}\n </Text>\n {/*\n * The same counter the rail shows, so a destination that is asking for\n * attention still says so here — the catalog is the only place an unfavourited\n * one is visible at all.\n */}\n {!!tag && <BadgeTag data={tag} className={styles.badge} />}\n </NavigationComponent>\n {/*\n * One control for the whole module: favouriting it takes its pages with it, and\n * unfavouriting it takes them away again.\n *\n * A gold outline marks a partially favourited module. Fill the star only when\n * its full, unfiltered scope is favourited, including pages hidden by search.\n * Clicking the outline adds the remaining pages; clicking the filled star removes all.\n */}\n {isLocked(item.id) ? (\n <LockedPin />\n ) : (\n <PinToggle\n pinned={allPinned ? true : anyPinned ? 'mixed' : false}\n label={\n allPinned\n ? `Unpin ${item.title} from the navigation`\n : `Pin ${item.title} to the navigation`\n }\n onToggle={() => onToggleAll(everything, !allPinned)}\n />\n )}\n </div>\n {submenuGroups.map(group => (\n <div key={group.links[0].id}>\n {group.title && (\n <Text variant=\"eyebrow\" size=\"small\" className={styles.submenuTitle}>\n {group.title}\n </Text>\n )}\n {group.links.map(child => {\n /*\n * Page titles are only unique within their item — \"Settings\" and \"Reports\" recur\n * across the catalog — and the row that tells them apart is a separate element, so\n * it is not part of any of these names. Anything reading the rows on their own, a\n * screen reader running through the links or a voice command naming one, would\n * otherwise be given the same name several times.\n */\n const qualifiedTitle = `${item.title}: ${child.title}`;\n const childPinned = isPinned(child.id);\n const childTag = getCounterTag(child.counter, child.tag);\n\n return (\n <div key={child.id} className={styles.row}>\n <NavigationComponent\n to={child.to}\n className={classNames(styles.link, styles.childLink)}\n onClick={onNavigate}\n aria-label={qualifiedTitle}\n >\n <Text inline className={styles.childTitle}>\n {child.title}\n </Text>\n {!!childTag && (\n <BadgeTag data={childTag} className={styles.badge} />\n )}\n </NavigationComponent>\n {isLocked(child.id) ? (\n <LockedPin />\n ) : (\n <PinToggle\n pinned={childPinned}\n label={\n childPinned\n ? `Unpin ${qualifiedTitle} from the navigation`\n : `Pin ${qualifiedTitle} to the navigation`\n }\n onToggle={() => onTogglePin(child.id, item.id)}\n />\n )}\n </div>\n );\n })}\n </div>\n ))}\n </div>\n );\n};\n\nexport interface NavigationCatalogProps extends PinnedNavigationConfig {\n open: boolean;\n onClose: () => void;\n}\n\n/**\n * Every destination the host offers, grouped into sections, with a star on every row.\n *\n * The catalog shows what it is given and nothing more — the host has already resolved what this\n * user may reach, so the catalog cannot offer a destination they do not have.\n */\nexport const NavigationCatalog: FC<NavigationCatalogProps> = ({\n open,\n onClose,\n groups,\n pinnedIds,\n lockedIds,\n onPinnedIdsChange,\n onResetToDefaults,\n}) => {\n const [query, setQuery] = useState('');\n const isSearching = query.trim().length > 0;\n const searchRef = useRef<HTMLInputElement>(null);\n\n /*\n * Resolved here rather than by the host, so a catalog that costs requests to enumerate is paid\n * for on opening the dialog and not on every page load. This component is mounted only while\n * open, so the call happens once per open unless the host changes the function.\n */\n const resolvedGroups = useMemo(() => groups(), [groups]);\n\n const visibleGroups = useMemo(\n () => filterCatalogGroups(resolvedGroups, query),\n [resolvedGroups, query]\n );\n\n /*\n * The pin scope of every item, keyed by id and built before the search narrows anything, so a\n * module's control still covers the pages a query is currently hiding.\n */\n const scopeById = useMemo(\n () =>\n new Map(\n resolvedGroups\n .flatMap(group => group.items)\n .map(item => [\n item.id,\n [item.id, ...getCatalogChildren(item).map(child => child.id)],\n ])\n ),\n [resolvedGroups]\n );\n const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);\n const lockedSet = useMemo(() => new Set(lockedIds ?? []), [lockedIds]);\n\n const isPinned = (id: string) => pinnedSet.has(id);\n const isLocked = (id: string) => lockedSet.has(id);\n\n /**\n * Favourites or unfavourites one row, carrying its module with it.\n *\n * A page is reached through its module, so the two travel together: favouriting a page brings\n * the module along when it is not already there, and unfavouriting the last one takes the\n * module with it. Leaving the module behind on its own would not read as an emptier\n * navigation — it would put the module back carrying every page it has.\n *\n * Locked ids never reach a proposal. The controls that would move them are not rendered, so\n * that part is belt and braces — but the host's list is the thing that gets persisted, and a\n * locked destination has no business appearing in or disappearing from it.\n */\n const togglePin = (id: string, ownerId?: string) => {\n if (lockedSet.has(id)) {\n return;\n }\n\n /** The module, when it is a separate row that this page can legitimately move. */\n const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;\n\n if (pinnedSet.has(id)) {\n const removing = new Set([id]);\n\n if (owner) {\n const siblings = (scopeById.get(owner) ?? []).filter(\n scoped => scoped !== owner && scoped !== id\n );\n\n if (!siblings.some(sibling => pinnedSet.has(sibling))) {\n removing.add(owner);\n }\n }\n\n onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));\n return;\n }\n\n // Module first, so it reads ahead of its own page in the saved order.\n const additions = owner && !pinnedSet.has(owner) ? [owner, id] : [id];\n\n onPinnedIdsChange([...pinnedIds, ...additions]);\n };\n\n /*\n * One proposal for the whole set rather than one per row, so a host that persists on every\n * change writes once, and every intermediate list — states the user never asked for — never\n * exists.\n */\n const toggleAll = (ids: string[], shouldPin: boolean) => {\n const actionable = ids.filter(id => !lockedSet.has(id));\n\n if (!shouldPin) {\n const removing = new Set(actionable);\n\n onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));\n return;\n }\n\n const additions = actionable.filter(id => !pinnedSet.has(id));\n\n if (additions.length > 0) {\n onPinnedIdsChange([...pinnedIds, ...additions]);\n }\n };\n\n const close = () => {\n setQuery('');\n onClose();\n };\n\n return (\n <Dialog\n open={open}\n onClose={close}\n size=\"xlarge\"\n aria-label=\"All Navigation\"\n className={styles.dialog}\n data-cy=\"navigation-catalog-dialog\"\n >\n <Dialog.Content className={styles.content}>\n <div className={styles.body}>\n <SearchField\n ref={searchRef}\n size=\"small\"\n className={styles.search}\n placeholder=\"Go to ...\"\n value={query}\n onChange={event => setQuery(event.target.value)}\n onClear={() => setQuery('')}\n aria-label=\"Go to\"\n />\n <div\n className={classNames(styles.results, isSearching && styles.searchResults)}\n >\n {visibleGroups.length === 0 ? (\n <div className={styles.empty}>\n {/*\n * Two different nothings: a search that matched no row, and a host\n * that offered no rows at all. The second is a real state — the\n * catalog is reachable whether or not there is anything in it — and\n * quoting an empty query at the user does not describe it.\n */}\n <Text subdued>\n {isSearching ? (\n <Fragment>\n No navigation items match “{query}”.\n </Fragment>\n ) : (\n 'There are no navigation items to show.'\n )}\n </Text>\n </div>\n ) : (\n <div className={isSearching ? undefined : styles.columns}>\n {visibleGroups.map(group => (\n <div\n key={group.title}\n className={isSearching ? undefined : styles.group}\n >\n {/*\n * Browse uses titled columns. Search stacks matches at full\n * width, keeping only each module's own submenu headings.\n */}\n {!isSearching && (\n <Text\n variant=\"eyebrow\"\n size=\"medium\"\n className={styles.groupTitle}\n >\n {group.title}\n </Text>\n )}\n {group.items.map(item => (\n <CatalogRows\n key={item.id}\n item={item}\n scopeIds={scopeById.get(item.id) ?? [item.id]}\n isPinned={isPinned}\n isLocked={isLocked}\n onTogglePin={togglePin}\n onToggleAll={toggleAll}\n onNavigate={close}\n />\n ))}\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n </Dialog.Content>\n <NavigationCatalogFooter\n onRestore={\n onResetToDefaults &&\n (() => {\n onResetToDefaults();\n // The control disappears with the host's reset; keep focus in the dialog.\n searchRef.current?.focus();\n })\n }\n />\n </Dialog>\n );\n};\n"],"names":["Button","Dialog","Icon","SearchField","Text","Tooltip","IconLock","IconStar","IconStarBorder","classNames","Fragment","useMemo","useRef","useState","filterCatalogGroups","getCatalogChildren","getCatalogSubmenuGroups","getCounterTag","BadgeTag","useTitanLayoutContext","NavigationCatalogFooter","styles","LockedPin","Trigger","svg","className","lock","Content","style","pointerEvents","PinToggle","pinned","label","onToggle","toggle","event","preventDefault","stopPropagation","delay","appearance","size","icon","star","starActive","aria-label","aria-pressed","data-pendo","onClick","CatalogRows","item","scopeIds","isPinned","isLocked","onTogglePin","onToggleAll","onNavigate","NavigationComponent","submenuGroups","everything","filter","id","anyPinned","some","allPinned","length","every","tag","counter","div","moduleGroup","row","to","link","title","moduleIcon","inline","moduleTitle","data","badge","map","group","variant","submenuTitle","links","child","qualifiedTitle","childPinned","childTag","childLink","childTitle","NavigationCatalog","open","onClose","groups","pinnedIds","lockedIds","onPinnedIdsChange","onResetToDefaults","query","setQuery","isSearching","trim","searchRef","resolvedGroups","visibleGroups","scopeById","Map","flatMap","items","pinnedSet","Set","lockedSet","has","togglePin","ownerId","owner","undefined","removing","siblings","get","scoped","sibling","add","additions","toggleAll","ids","shouldPin","actionable","close","dialog","data-cy","content","body","ref","search","placeholder","value","onChange","target","onClear","results","searchResults","empty","subdued","columns","groupTitle","onRestore","current","focus"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../src/components/titan-layout/navigation-catalog/navigation-catalog.tsx"],"sourcesContent":["import { Button, Dialog, Icon, SearchField, Text, Tooltip } from '@servicetitan/anvil2';\nimport IconLock from '@servicetitan/anvil2/assets/icons/material/round/lock.svg';\nimport IconStar from '@servicetitan/anvil2/assets/icons/material/round/star.svg';\nimport IconStarBorder from '@servicetitan/anvil2/assets/icons/material/round/star_border.svg';\nimport classNames from 'classnames';\nimport { FC, Fragment, MouseEvent, useMemo, useRef, useState } from 'react';\nimport {\n filterCatalogGroups,\n getCatalogChildren,\n getCatalogSubmenuGroups,\n} from '../../../utils/catalog-groups';\nimport { NavigationItemData } from '../../../utils/navigation';\nimport { getCounterTag } from '../../../utils/side-nav';\nimport { BadgeTag } from '../../badge-tag';\nimport { PinnedNavigationConfig } from '../interface';\nimport { useTitanLayoutContext } from '../layout-context';\nimport { NavigationCatalogFooter } from './navigation-catalog-footer';\nimport * as styles from './navigation-catalog.module.less';\n\n/** Shown in place of the pin control for a destination the user may not unpin. */\nconst LockedPin: FC = () => (\n <Tooltip>\n <Tooltip.Trigger>\n <Icon svg={IconLock} className={styles.lock} />\n </Tooltip.Trigger>\n <Tooltip.Content style={{ pointerEvents: 'none' }}>\n Cannot be removed from Favorites\n </Tooltip.Content>\n </Tooltip>\n);\n\ninterface PinToggleProps {\n /** `mixed` is a module on the rail with only some of its pages: an outline, still active. */\n pinned: boolean | 'mixed';\n label: string;\n onToggle: () => void;\n}\n\nconst PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {\n const toggle = (event: MouseEvent) => {\n // The control sits inside the row but is not a navigation affordance.\n event.preventDefault();\n event.stopPropagation();\n onToggle();\n };\n\n return (\n <Tooltip delay={300}>\n <Tooltip.Trigger>\n <Button\n appearance=\"ghost\"\n size=\"small\"\n icon={pinned === true ? IconStar : IconStarBorder}\n className={classNames(styles.star, pinned && styles.starActive)}\n aria-label={label}\n aria-pressed={pinned}\n data-pendo=\"global-nav-favorite-star\"\n onClick={toggle}\n />\n </Tooltip.Trigger>\n <Tooltip.Content style={{ pointerEvents: 'none' }}>\n {pinned ? 'Remove from Favorites' : 'Add to Favorites'}\n </Tooltip.Content>\n </Tooltip>\n );\n};\n\ninterface CatalogRowsProps {\n item: NavigationItemData;\n /**\n * Every id the module's own control covers, taken from the unfiltered item.\n *\n * `item` may have been narrowed by the search, so deriving this from it would let the control\n * act on only the pages that happen to be visible — silently stranding the rest.\n */\n scopeIds: string[];\n isPinned: (id: string) => boolean;\n isLocked: (id: string) => boolean;\n onToggleModule: (moduleId: string) => void;\n onTogglePage: (id: string, moduleId: string) => void;\n onNavigate: () => void;\n}\n\nconst CatalogRows: FC<CatalogRowsProps> = ({\n item,\n scopeIds,\n isPinned,\n isLocked,\n onToggleModule,\n onTogglePage,\n onNavigate,\n}) => {\n const { NavigationComponent } = useTitanLayoutContext();\n const submenuGroups = getCatalogSubmenuGroups(item);\n\n /*\n * A pinned module id means the whole module, every page included. Pinned page ids without\n * their module mean the module is on the rail carrying only those pages.\n */\n const isWholeModule = isPinned(item.id);\n const isOnRail = scopeIds.some(isPinned);\n const pageIds = scopeIds.filter(id => id !== item.id && !isLocked(id));\n const isFullyPinned = isWholeModule || (pageIds.length > 0 && pageIds.every(isPinned));\n const tag = getCounterTag(item.counter, item.tag);\n\n return (\n <div className={styles.moduleGroup}>\n <div className={styles.row}>\n <NavigationComponent\n to={item.to}\n className={styles.link}\n onClick={onNavigate}\n aria-label={item.title}\n >\n {item.icon && (\n <Icon svg={item.icon} size=\"medium\" className={styles.moduleIcon} />\n )}\n <Text inline size=\"small\" className={styles.moduleTitle}>\n {item.title}\n </Text>\n {/*\n * The same counter the rail shows, so a destination that is asking for\n * attention still says so here — the catalog is the only place an unfavourited\n * one is visible at all.\n */}\n {!!tag && <BadgeTag data={tag} className={styles.badge} />}\n </NavigationComponent>\n {/*\n * Filled when every page is favourited, an outline when only some are. Either way\n * the module is on the rail, so one click takes it off with all of its pages.\n */}\n {isLocked(item.id) ? (\n <LockedPin />\n ) : (\n <PinToggle\n pinned={isFullyPinned ? true : isOnRail ? 'mixed' : false}\n label={\n isOnRail\n ? `Unpin ${item.title} from the navigation`\n : `Pin ${item.title} to the navigation`\n }\n onToggle={() => onToggleModule(item.id)}\n />\n )}\n </div>\n {submenuGroups.map(group => (\n <div key={group.links[0].id}>\n {group.title && (\n <Text variant=\"eyebrow\" size=\"small\" className={styles.submenuTitle}>\n {group.title}\n </Text>\n )}\n {group.links.map(child => {\n /*\n * Page titles are only unique within their item — \"Settings\" and \"Reports\" recur\n * across the catalog — and the row that tells them apart is a separate element, so\n * it is not part of any of these names. Anything reading the rows on their own, a\n * screen reader running through the links or a voice command naming one, would\n * otherwise be given the same name several times.\n */\n const qualifiedTitle = `${item.title}: ${child.title}`;\n const childPinned = isWholeModule || isPinned(child.id);\n const childTag = getCounterTag(child.counter, child.tag);\n\n return (\n <div key={child.id} className={styles.row}>\n <NavigationComponent\n to={child.to}\n className={classNames(styles.link, styles.childLink)}\n onClick={onNavigate}\n aria-label={qualifiedTitle}\n >\n <Text inline className={styles.childTitle}>\n {child.title}\n </Text>\n {!!childTag && (\n <BadgeTag data={childTag} className={styles.badge} />\n )}\n </NavigationComponent>\n {isLocked(child.id) ? (\n <LockedPin />\n ) : (\n <PinToggle\n pinned={childPinned}\n label={\n childPinned\n ? `Unpin ${qualifiedTitle} from the navigation`\n : `Pin ${qualifiedTitle} to the navigation`\n }\n onToggle={() => onTogglePage(child.id, item.id)}\n />\n )}\n </div>\n );\n })}\n </div>\n ))}\n </div>\n );\n};\n\nexport interface NavigationCatalogProps extends PinnedNavigationConfig {\n open: boolean;\n onClose: () => void;\n}\n\n/**\n * Every destination the host offers, grouped into sections, with a star on every row.\n *\n * The catalog shows what it is given and nothing more — the host has already resolved what this\n * user may reach, so the catalog cannot offer a destination they do not have.\n */\nexport const NavigationCatalog: FC<NavigationCatalogProps> = ({\n open,\n onClose,\n groups,\n pinnedIds,\n lockedIds,\n onPinnedIdsChange,\n onResetToDefaults,\n}) => {\n const [query, setQuery] = useState('');\n const isSearching = query.trim().length > 0;\n const searchRef = useRef<HTMLInputElement>(null);\n\n /*\n * Resolved here rather than by the host, so a catalog that costs requests to enumerate is paid\n * for on opening the dialog and not on every page load. This component is mounted only while\n * open, so the call happens once per open unless the host changes the function.\n */\n const resolvedGroups = useMemo(() => groups(), [groups]);\n\n const visibleGroups = useMemo(\n () => filterCatalogGroups(resolvedGroups, query),\n [resolvedGroups, query]\n );\n\n /*\n * The pin scope of every item, keyed by id and built before the search narrows anything, so a\n * module's control still covers the pages a query is currently hiding.\n */\n const scopeById = useMemo(\n () =>\n new Map(\n resolvedGroups\n .flatMap(group => group.items)\n .map(item => [\n item.id,\n [item.id, ...getCatalogChildren(item).map(child => child.id)],\n ])\n ),\n [resolvedGroups]\n );\n const pinnedSet = useMemo(() => new Set(pinnedIds), [pinnedIds]);\n const lockedSet = useMemo(() => new Set(lockedIds ?? []), [lockedIds]);\n\n const isPinned = (id: string) => pinnedSet.has(id);\n const isLocked = (id: string) => lockedSet.has(id);\n\n /*\n * The saved list follows the host's model: a module id pins the whole module, and page ids\n * without their module pin the module carrying only those pages. A module id is therefore\n * never saved next to its own page ids — the host would read that as the whole module.\n *\n * Locked ids never reach a proposal. The controls that would move them are not rendered, so\n * that part is belt and braces — but the host's list is the thing that gets persisted, and a\n * locked destination has no business appearing in or disappearing from it.\n */\n\n /** Takes a module off the rail with all of its pages, or puts the whole module on it. */\n const toggleModule = (moduleId: string) => {\n if (lockedSet.has(moduleId)) {\n return;\n }\n\n const scope = (scopeById.get(moduleId) ?? [moduleId]).filter(id => !lockedSet.has(id));\n const scopeSet = new Set(scope);\n const withoutModule = pinnedIds.filter(pinned => !scopeSet.has(pinned));\n\n onPinnedIdsChange(\n scope.some(id => pinnedSet.has(id)) ? withoutModule : [...withoutModule, moduleId]\n );\n };\n\n /**\n * Favourites or unfavourites a single page.\n *\n * Unstarring a page of a wholly favourited module narrows the module to the rest of its pages,\n * in the module's place; unstarring its last page takes the module off the rail.\n */\n const togglePage = (id: string, moduleId: string) => {\n if (lockedSet.has(id)) {\n return;\n }\n\n const scope = scopeById.get(moduleId) ?? [moduleId];\n const pages = scope.filter(scoped => scoped !== moduleId && !lockedSet.has(scoped));\n\n if (pinnedSet.has(moduleId) && !lockedSet.has(moduleId)) {\n const scopeSet = new Set(scope);\n const rest = pages.filter(page => page !== id);\n\n onPinnedIdsChange(\n pinnedIds.flatMap(pinned =>\n pinned === moduleId ? rest : scopeSet.has(pinned) ? [] : [pinned]\n )\n );\n return;\n }\n\n onPinnedIdsChange(\n pinnedSet.has(id) ? pinnedIds.filter(pinned => pinned !== id) : [...pinnedIds, id]\n );\n };\n\n const close = () => {\n setQuery('');\n onClose();\n };\n\n return (\n <Dialog\n open={open}\n onClose={close}\n size=\"xlarge\"\n aria-label=\"All Navigation\"\n className={styles.dialog}\n data-cy=\"navigation-catalog-dialog\"\n >\n <Dialog.Content className={styles.content}>\n <div className={styles.body}>\n <SearchField\n ref={searchRef}\n size=\"small\"\n className={styles.search}\n placeholder=\"Go to ...\"\n value={query}\n onChange={event => setQuery(event.target.value)}\n onClear={() => setQuery('')}\n aria-label=\"Go to\"\n />\n <div\n className={classNames(styles.results, isSearching && styles.searchResults)}\n >\n {visibleGroups.length === 0 ? (\n <div className={styles.empty}>\n {/*\n * Two different nothings: a search that matched no row, and a host\n * that offered no rows at all. The second is a real state — the\n * catalog is reachable whether or not there is anything in it — and\n * quoting an empty query at the user does not describe it.\n */}\n <Text subdued>\n {isSearching ? (\n <Fragment>\n No navigation items match “{query}”.\n </Fragment>\n ) : (\n 'There are no navigation items to show.'\n )}\n </Text>\n </div>\n ) : (\n <div className={isSearching ? undefined : styles.columns}>\n {visibleGroups.map(group => (\n <div\n key={group.title}\n className={isSearching ? undefined : styles.group}\n >\n {/*\n * Browse uses titled columns. Search stacks matches at full\n * width, keeping only each module's own submenu headings.\n */}\n {!isSearching && (\n <Text\n variant=\"eyebrow\"\n size=\"medium\"\n className={styles.groupTitle}\n >\n {group.title}\n </Text>\n )}\n {group.items.map(item => (\n <CatalogRows\n key={item.id}\n item={item}\n scopeIds={scopeById.get(item.id) ?? [item.id]}\n isPinned={isPinned}\n isLocked={isLocked}\n onToggleModule={toggleModule}\n onTogglePage={togglePage}\n onNavigate={close}\n />\n ))}\n </div>\n ))}\n </div>\n )}\n </div>\n </div>\n </Dialog.Content>\n <NavigationCatalogFooter\n onRestore={\n onResetToDefaults &&\n (() => {\n onResetToDefaults();\n // The control disappears with the host's reset; keep focus in the dialog.\n searchRef.current?.focus();\n })\n }\n />\n </Dialog>\n );\n};\n"],"names":["Button","Dialog","Icon","SearchField","Text","Tooltip","IconLock","IconStar","IconStarBorder","classNames","Fragment","useMemo","useRef","useState","filterCatalogGroups","getCatalogChildren","getCatalogSubmenuGroups","getCounterTag","BadgeTag","useTitanLayoutContext","NavigationCatalogFooter","styles","LockedPin","Trigger","svg","className","lock","Content","style","pointerEvents","PinToggle","pinned","label","onToggle","toggle","event","preventDefault","stopPropagation","delay","appearance","size","icon","star","starActive","aria-label","aria-pressed","data-pendo","onClick","CatalogRows","item","scopeIds","isPinned","isLocked","onToggleModule","onTogglePage","onNavigate","NavigationComponent","submenuGroups","isWholeModule","id","isOnRail","some","pageIds","filter","isFullyPinned","length","every","tag","counter","div","moduleGroup","row","to","link","title","moduleIcon","inline","moduleTitle","data","badge","map","group","variant","submenuTitle","links","child","qualifiedTitle","childPinned","childTag","childLink","childTitle","NavigationCatalog","open","onClose","groups","pinnedIds","lockedIds","onPinnedIdsChange","onResetToDefaults","query","setQuery","isSearching","trim","searchRef","resolvedGroups","visibleGroups","scopeById","Map","flatMap","items","pinnedSet","Set","lockedSet","has","toggleModule","moduleId","scope","get","scopeSet","withoutModule","togglePage","pages","scoped","rest","page","close","dialog","data-cy","content","body","ref","search","placeholder","value","onChange","target","onClear","results","searchResults","empty","subdued","undefined","columns","groupTitle","onRestore","current","focus"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@servicetitan/navigation",
|
|
3
|
-
"version": "14.5.
|
|
3
|
+
"version": "14.5.1",
|
|
4
4
|
"description": "Navigation components",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"less": true,
|
|
36
36
|
"webpack": false
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "641fcf58999dd57e39c199ca97fddfc0aa224a4c"
|
|
39
39
|
}
|
|
@@ -133,22 +133,74 @@ it('leaves favorites untouched until the host applies the reset', () => {
|
|
|
133
133
|
const props = renderCatalog();
|
|
134
134
|
click(button('Restore Default Favorites')!);
|
|
135
135
|
click(button('Restore')!);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
* `mixed` here, because two of Marketing's three ids are favourited and not the third.
|
|
139
|
-
*/
|
|
140
|
-
expect(marketingStar().getAttribute('aria-pressed')).toBe('mixed');
|
|
136
|
+
// Fully controlled: the catalog has proposed nothing, so the rows still read as they did.
|
|
137
|
+
expect(marketingStar().getAttribute('aria-pressed')).toBe('true');
|
|
141
138
|
expect(props.onPinnedIdsChange).not.toHaveBeenCalled();
|
|
142
139
|
});
|
|
143
140
|
|
|
144
141
|
it('keeps star edits on onPinnedIdsChange without calling the reset handler', () => {
|
|
145
142
|
const props = renderCatalog();
|
|
146
|
-
// A partly favourited module completes itself rather than clearing: campaigns joins the list.
|
|
147
143
|
click(marketingStar());
|
|
148
|
-
expect(props.onPinnedIdsChange).toHaveBeenCalledWith([
|
|
144
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith([]);
|
|
149
145
|
expect(props.onResetToDefaults).not.toHaveBeenCalled();
|
|
150
146
|
});
|
|
151
147
|
|
|
148
|
+
describe('favourite stars', () => {
|
|
149
|
+
const star = (label: string) =>
|
|
150
|
+
container.querySelector(`button[aria-label="${label}"]`)?.getAttribute('aria-pressed');
|
|
151
|
+
const clickStar = (label: string) =>
|
|
152
|
+
click(container.querySelector(`button[aria-label="${label}"]`)!);
|
|
153
|
+
|
|
154
|
+
it('pins only the page, not its module, when one page is starred', () => {
|
|
155
|
+
const props = renderCatalog({ pinnedIds: ['other'] });
|
|
156
|
+
clickStar('Pin Marketing: Campaigns to the navigation');
|
|
157
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith(['other', 'campaigns']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('shows a module pinned by its id alone as filled, with every page starred', () => {
|
|
161
|
+
renderCatalog({ pinnedIds: ['marketing'] });
|
|
162
|
+
expect(star('Unpin Marketing from the navigation')).toBe('true');
|
|
163
|
+
expect(star('Unpin Marketing: Overview from the navigation')).toBe('true');
|
|
164
|
+
expect(star('Unpin Marketing: Campaigns from the navigation')).toBe('true');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('shows a module on the rail through one page as mixed, with only that page starred', () => {
|
|
168
|
+
renderCatalog({ pinnedIds: ['overview'] });
|
|
169
|
+
expect(star('Unpin Marketing from the navigation')).toBe('mixed');
|
|
170
|
+
expect(star('Unpin Marketing: Overview from the navigation')).toBe('true');
|
|
171
|
+
expect(star('Pin Marketing: Campaigns to the navigation')).toBe('false');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('shows a module as filled once every page is starred on its own', () => {
|
|
175
|
+
renderCatalog({ pinnedIds: ['overview', 'campaigns'] });
|
|
176
|
+
expect(star('Unpin Marketing from the navigation')).toBe('true');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('removes a partly favourited module and its pages in one click', () => {
|
|
180
|
+
const props = renderCatalog({ pinnedIds: ['other', 'overview'] });
|
|
181
|
+
clickStar('Unpin Marketing from the navigation');
|
|
182
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith(['other']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('pins an unfavourited module by its id alone', () => {
|
|
186
|
+
const props = renderCatalog({ pinnedIds: [] });
|
|
187
|
+
clickStar('Pin Marketing to the navigation');
|
|
188
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith(['marketing']);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('narrows a whole module to its remaining pages when one page is unstarred', () => {
|
|
192
|
+
const props = renderCatalog({ pinnedIds: ['a', 'marketing', 'b'] });
|
|
193
|
+
clickStar('Unpin Marketing: Overview from the navigation');
|
|
194
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith(['a', 'campaigns', 'b']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('takes the module off the rail when its last page is unstarred', () => {
|
|
198
|
+
const props = renderCatalog({ pinnedIds: ['overview'] });
|
|
199
|
+
clickStar('Unpin Marketing: Overview from the navigation');
|
|
200
|
+
expect(props.onPinnedIdsChange).toHaveBeenCalledWith([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
152
204
|
it('hides restore when the host does not offer one', () => {
|
|
153
205
|
renderCatalog({ onResetToDefaults: undefined });
|
|
154
206
|
expect(button('Restore Default Favorites')).toBeUndefined();
|
|
@@ -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;
|
|
@@ -59,11 +59,7 @@ const PinToggle: FC<PinToggleProps> = ({ pinned, label, onToggle }) => {
|
|
|
59
59
|
/>
|
|
60
60
|
</Tooltip.Trigger>
|
|
61
61
|
<Tooltip.Content style={{ pointerEvents: 'none' }}>
|
|
62
|
-
{pinned
|
|
63
|
-
? 'Add this module to Favorites'
|
|
64
|
-
: pinned
|
|
65
|
-
? 'Remove from Favorites'
|
|
66
|
-
: 'Add to Favorites'}
|
|
62
|
+
{pinned ? 'Remove from Favorites' : 'Add to Favorites'}
|
|
67
63
|
</Tooltip.Content>
|
|
68
64
|
</Tooltip>
|
|
69
65
|
);
|
|
@@ -80,9 +76,8 @@ interface CatalogRowsProps {
|
|
|
80
76
|
scopeIds: string[];
|
|
81
77
|
isPinned: (id: string) => boolean;
|
|
82
78
|
isLocked: (id: string) => boolean;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
onToggleAll: (ids: string[], shouldPin: boolean) => void;
|
|
79
|
+
onToggleModule: (moduleId: string) => void;
|
|
80
|
+
onTogglePage: (id: string, moduleId: string) => void;
|
|
86
81
|
onNavigate: () => void;
|
|
87
82
|
}
|
|
88
83
|
|
|
@@ -91,20 +86,21 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
91
86
|
scopeIds,
|
|
92
87
|
isPinned,
|
|
93
88
|
isLocked,
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
onToggleModule,
|
|
90
|
+
onTogglePage,
|
|
96
91
|
onNavigate,
|
|
97
92
|
}) => {
|
|
98
93
|
const { NavigationComponent } = useTitanLayoutContext();
|
|
99
94
|
const submenuGroups = getCatalogSubmenuGroups(item);
|
|
100
95
|
|
|
101
96
|
/*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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.
|
|
104
99
|
*/
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
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));
|
|
108
104
|
const tag = getCounterTag(item.counter, item.tag);
|
|
109
105
|
|
|
110
106
|
return (
|
|
@@ -130,24 +126,20 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
130
126
|
{!!tag && <BadgeTag data={tag} className={styles.badge} />}
|
|
131
127
|
</NavigationComponent>
|
|
132
128
|
{/*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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.
|
|
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.
|
|
139
131
|
*/}
|
|
140
132
|
{isLocked(item.id) ? (
|
|
141
133
|
<LockedPin />
|
|
142
134
|
) : (
|
|
143
135
|
<PinToggle
|
|
144
|
-
pinned={
|
|
136
|
+
pinned={isFullyPinned ? true : isOnRail ? 'mixed' : false}
|
|
145
137
|
label={
|
|
146
|
-
|
|
138
|
+
isOnRail
|
|
147
139
|
? `Unpin ${item.title} from the navigation`
|
|
148
140
|
: `Pin ${item.title} to the navigation`
|
|
149
141
|
}
|
|
150
|
-
onToggle={() =>
|
|
142
|
+
onToggle={() => onToggleModule(item.id)}
|
|
151
143
|
/>
|
|
152
144
|
)}
|
|
153
145
|
</div>
|
|
@@ -167,7 +159,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
167
159
|
* otherwise be given the same name several times.
|
|
168
160
|
*/
|
|
169
161
|
const qualifiedTitle = `${item.title}: ${child.title}`;
|
|
170
|
-
const childPinned = isPinned(child.id);
|
|
162
|
+
const childPinned = isWholeModule || isPinned(child.id);
|
|
171
163
|
const childTag = getCounterTag(child.counter, child.tag);
|
|
172
164
|
|
|
173
165
|
return (
|
|
@@ -195,7 +187,7 @@ const CatalogRows: FC<CatalogRowsProps> = ({
|
|
|
195
187
|
? `Unpin ${qualifiedTitle} from the navigation`
|
|
196
188
|
: `Pin ${qualifiedTitle} to the navigation`
|
|
197
189
|
}
|
|
198
|
-
onToggle={() =>
|
|
190
|
+
onToggle={() => onTogglePage(child.id, item.id)}
|
|
199
191
|
/>
|
|
200
192
|
)}
|
|
201
193
|
</div>
|
|
@@ -265,69 +257,60 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
265
257
|
const isPinned = (id: string) => pinnedSet.has(id);
|
|
266
258
|
const isLocked = (id: string) => lockedSet.has(id);
|
|
267
259
|
|
|
268
|
-
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* the module along when it is not already there, and unfavouriting the last one takes the
|
|
273
|
-
* module with it. Leaving the module behind on its own would not read as an emptier
|
|
274
|
-
* 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.
|
|
275
264
|
*
|
|
276
265
|
* Locked ids never reach a proposal. The controls that would move them are not rendered, so
|
|
277
266
|
* that part is belt and braces — but the host's list is the thing that gets persisted, and a
|
|
278
267
|
* locked destination has no business appearing in or disappearing from it.
|
|
279
268
|
*/
|
|
280
|
-
const togglePin = (id: string, ownerId?: string) => {
|
|
281
|
-
if (lockedSet.has(id)) {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** The module, when it is a separate row that this page can legitimately move. */
|
|
286
|
-
const owner = ownerId !== id && ownerId && !lockedSet.has(ownerId) ? ownerId : undefined;
|
|
287
|
-
|
|
288
|
-
if (pinnedSet.has(id)) {
|
|
289
|
-
const removing = new Set([id]);
|
|
290
|
-
|
|
291
|
-
if (owner) {
|
|
292
|
-
const siblings = (scopeById.get(owner) ?? []).filter(
|
|
293
|
-
scoped => scoped !== owner && scoped !== id
|
|
294
|
-
);
|
|
295
|
-
|
|
296
|
-
if (!siblings.some(sibling => pinnedSet.has(sibling))) {
|
|
297
|
-
removing.add(owner);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
269
|
|
|
301
|
-
|
|
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)) {
|
|
302
273
|
return;
|
|
303
274
|
}
|
|
304
275
|
|
|
305
|
-
|
|
306
|
-
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));
|
|
307
279
|
|
|
308
|
-
onPinnedIdsChange(
|
|
280
|
+
onPinnedIdsChange(
|
|
281
|
+
scope.some(id => pinnedSet.has(id)) ? withoutModule : [...withoutModule, moduleId]
|
|
282
|
+
);
|
|
309
283
|
};
|
|
310
284
|
|
|
311
|
-
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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.
|
|
315
290
|
*/
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (!shouldPin) {
|
|
320
|
-
const removing = new Set(actionable);
|
|
321
|
-
|
|
322
|
-
onPinnedIdsChange(pinnedIds.filter(pinned => !removing.has(pinned)));
|
|
291
|
+
const togglePage = (id: string, moduleId: string) => {
|
|
292
|
+
if (lockedSet.has(id)) {
|
|
323
293
|
return;
|
|
324
294
|
}
|
|
325
295
|
|
|
326
|
-
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);
|
|
327
302
|
|
|
328
|
-
|
|
329
|
-
|
|
303
|
+
onPinnedIdsChange(
|
|
304
|
+
pinnedIds.flatMap(pinned =>
|
|
305
|
+
pinned === moduleId ? rest : scopeSet.has(pinned) ? [] : [pinned]
|
|
306
|
+
)
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
330
309
|
}
|
|
310
|
+
|
|
311
|
+
onPinnedIdsChange(
|
|
312
|
+
pinnedSet.has(id) ? pinnedIds.filter(pinned => pinned !== id) : [...pinnedIds, id]
|
|
313
|
+
);
|
|
331
314
|
};
|
|
332
315
|
|
|
333
316
|
const close = () => {
|
|
@@ -404,8 +387,8 @@ export const NavigationCatalog: FC<NavigationCatalogProps> = ({
|
|
|
404
387
|
scopeIds={scopeById.get(item.id) ?? [item.id]}
|
|
405
388
|
isPinned={isPinned}
|
|
406
389
|
isLocked={isLocked}
|
|
407
|
-
|
|
408
|
-
|
|
390
|
+
onToggleModule={toggleModule}
|
|
391
|
+
onTogglePage={togglePage}
|
|
409
392
|
onNavigate={close}
|
|
410
393
|
/>
|
|
411
394
|
))}
|