Skip to content

Commit 42bf11c

Browse files
authored
Merge pull request #330 from codex-team/feat/deep-page-nesting
PR title: Fix deep page nesting in sidebar, prev/next order, and parent dropdown indent
2 parents 9993be5 + d1ff372 commit 42bf11c

14 files changed

Lines changed: 310 additions & 189 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "codex.docs",
33
"license": "Apache-2.0",
4-
"version": "2.2.4",
4+
"version": "2.3.0",
55
"type": "module",
66
"bin": {
77
"codex.docs": "dist/backend/app.js"

src/backend/build-static.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,7 @@ export default async function buildStatic(): Promise<void> {
105105
const parentIdOfRootPages = '0' as EntityId;
106106
const previousPage = await PagesFlatArray.getPageBefore(pageId);
107107
const nextPage = await PagesFlatArray.getPageAfter(pageId);
108-
const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder, 2);
109-
108+
const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder);
110109
const result = await renderTemplate('./views/pages/page.twig', {
111110
page,
112111
pageParent,

src/backend/controllers/pages.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,21 +87,53 @@ class Pages {
8787
}
8888

8989
/**
90-
* Group all pages by their parents
91-
* If the pageId is passed, it excludes passed page from result pages
90+
* Depth in parent chain: 0 for root pages, +1 per ancestor below root (for select indent).
91+
*/
92+
private static computePageDepth(page: Page, pagesMap: Map<string, Page>): number {
93+
let depth = 0;
94+
let cur: Page | undefined = page;
95+
96+
while (cur?._parent && !isEqualIds(cur._parent, '0' as EntityId)) {
97+
depth++;
98+
cur = pagesMap.get(cur._parent.toString());
99+
}
100+
101+
return depth;
102+
}
103+
104+
/**
105+
* Ordered pages for the parent `<select>` with nesting depth (indent in the template).
92106
*
93-
* @param {string} pageId - pageId to exclude from result pages
94-
* @returns {Page[]}
107+
* @param excludePageId - when editing, exclude this page and its descendants (same as groupByParent)
95108
*/
96-
public static async groupByParent(pageId = '' as EntityId): Promise<Page[]> {
109+
public static async getParentSelectOptions(
110+
excludePageId?: EntityId
111+
): Promise<Array<{ page: Page; depth: number }>> {
112+
const { pages, pagesMap } = excludePageId
113+
? await this.groupByParentWithMap(excludePageId)
114+
: await this.groupByParentWithMap('' as EntityId);
115+
116+
return pages.map((page) => ({
117+
page,
118+
depth: Pages.computePageDepth(page, pagesMap),
119+
}));
120+
}
121+
122+
/**
123+
* Same as {@link groupByParent} but returns the pages map from the same load (no second getPagesMap).
124+
*/
125+
private static async groupByParentWithMap(pageId = '' as EntityId): Promise<{
126+
pages: Page[];
127+
pagesMap: Map<string, Page>;
128+
}> {
97129
const rootPageOrder = await PagesOrder.getRootPageOrder(); // get order of the root pages
98130
const childPageOrder = await PagesOrder.getChildPageOrder(); // get order of the all other pages
99131

100132
/**
101133
* If there is no root and child page order, then it returns an empty array
102134
*/
103135
if (!rootPageOrder || (!rootPageOrder && childPageOrder.length <= 0)) {
104-
return [];
136+
return { pages: [], pagesMap: new Map() };
105137
}
106138

107139
const pagesMap = await this.getPagesMap();
@@ -140,16 +172,31 @@ class Pages {
140172
* Otherwise just returns result itself
141173
*/
142174
if (pageId) {
143-
return this.removeChildren(result, pageId).reduce((prev, curr) => {
175+
const pages = this.removeChildren(result, pageId).reduce((prev, curr) => {
144176
if (curr instanceof Page) {
145177
prev.push(curr);
146178
}
147179

148180
return prev;
149181
}, Array<Page>());
150-
} else {
151-
return result;
182+
183+
return { pages, pagesMap };
152184
}
185+
186+
return { pages: result, pagesMap };
187+
}
188+
189+
/**
190+
* Group all pages by their parents
191+
* If the pageId is passed, it excludes passed page from result pages
192+
*
193+
* @param {string} pageId - pageId to exclude from result pages
194+
* @returns {Page[]}
195+
*/
196+
public static async groupByParent(pageId = '' as EntityId): Promise<Page[]> {
197+
const { pages } = await this.groupByParentWithMap(pageId);
198+
199+
return pages;
153200
}
154201

155202
/**

src/backend/models/pagesFlatArray.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ class PagesFlatArray {
5151
/**
5252
* Returns pages flat array
5353
*
54-
* @param nestingLimit - number of flat array nesting, set null to dismiss the restriction, default nesting 2
54+
* @param nestingLimit - max `level` to keep (level 0 = root pages, 1 = their children, …).
55+
* Pass **null** to return the full tree order (needed for prev/next links past depth 2).
5556
* @returns {Promise<Array<PagesFlatArrayData>>}
5657
*/
5758
public static async get(nestingLimit: number | null = 2): Promise<Array<PagesFlatArrayData>> {
@@ -108,7 +109,8 @@ class PagesFlatArray {
108109
* @returns {Promise<PagesFlatArrayData | undefined>}
109110
*/
110111
public static async getPageBefore(pageId: EntityId): Promise<PagesFlatArrayData | undefined> {
111-
const arr = await this.get();
112+
/** `null` = no level cap; default (2) would drop pages at level ≥2 from the chain */
113+
const arr = await this.get(null);
112114

113115
const pageIndex = arr.findIndex((item) => isEqualIds(item.id, pageId));
114116

@@ -128,7 +130,7 @@ class PagesFlatArray {
128130
* @returns {Promise<PagesFlatArrayData | undefined>}
129131
*/
130132
public static async getPageAfter(pageId: EntityId): Promise<PagesFlatArrayData | undefined> {
131-
const arr = await this.get();
133+
const arr = await this.get(null);
132134

133135
const pageIndex = arr.findIndex( (item) => isEqualIds(item.id, pageId));
134136

src/backend/routes/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@ router.use('/', pagesMiddleware, home);
1414
router.use('/', pagesMiddleware, pages);
1515
router.use('/', pagesMiddleware, auth);
1616
router.use('/api', verifyToken, allowEdit, api);
17-
router.use('/', aliases);
17+
router.use('/', pagesMiddleware, aliases);
1818

1919
export default router;

src/backend/routes/middlewares/pages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default asyncMiddleware(async (req: Request, res: Response, next: NextFun
2525
const pages = await Pages.getAllPages();
2626
const pagesOrder = await PagesOrder.getAll();
2727

28-
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 2);
28+
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder);
2929
} catch (error) {
3030
console.log('Can not load menu:', error);
3131
}

src/backend/routes/pages.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@ const router = express.Router();
1313
*/
1414
router.get('/page/new', verifyToken, allowEdit, async (req: Request, res: Response, next: NextFunction) => {
1515
try {
16-
const pagesAvailableGrouped = await Pages.groupByParent();
17-
18-
console.log(pagesAvailableGrouped);
16+
const parentSelectOptions = await Pages.getParentSelectOptions();
1917

2018
res.render('pages/form', {
21-
pagesAvailableGrouped,
19+
parentSelectOptions,
2220
page: null,
2321
});
2422
} catch (error) {
@@ -36,7 +34,7 @@ router.get('/page/edit/:id', verifyToken, allowEdit, async (req: Request, res: R
3634
try {
3735
const page = await Pages.get(pageId);
3836
const pagesAvailable = await Pages.getAllExceptChildren(pageId);
39-
const pagesAvailableGrouped = await Pages.groupByParent(pageId);
37+
const parentSelectOptions = await Pages.getParentSelectOptions(pageId);
4038

4139
if (!page._parent) {
4240
throw new Error('Parent not found');
@@ -47,7 +45,7 @@ router.get('/page/edit/:id', verifyToken, allowEdit, async (req: Request, res: R
4745
res.render('pages/form', {
4846
page,
4947
parentsChildrenOrdered,
50-
pagesAvailableGrouped,
48+
parentSelectOptions,
5149
});
5250
} catch (error) {
5351
res.status(404);

src/backend/utils/menu.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,26 @@ import Page from '../models/page.js';
33
import PageOrder from '../models/pageOrder.js';
44
import { isEqualIds } from '../database/index.js';
55

6+
/** Max sidebar nesting depth (root sections count as depth 1). */
7+
const MENU_MAX_DEPTH = 64;
8+
69
/**
7-
* Process one-level pages list to parent-children list
10+
* Build parent→children menu tree for the sidebar.
811
*
912
* @param parentPageId - parent page id
1013
* @param pages - list of all available pages
1114
* @param pagesOrder - list of pages order
12-
* @param level - max level recursion
13-
* @param currentLevel - current level of element
15+
* @param maxDepth - stop recursing deeper than this (default: MENU_MAX_DEPTH)
16+
* currentDepth - current depth from the tree root (1 = direct children of parentPageId)
1417
*/
15-
export function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder: PageOrder[], level = 1, currentLevel = 1): Page[] {
16-
const childrenOrder = pagesOrder.find(order => isEqualIds(order.data.page, parentPageId));
18+
export function createMenuTree(
19+
parentPageId: EntityId,
20+
pages: Page[],
21+
pagesOrder: PageOrder[],
22+
maxDepth: number = MENU_MAX_DEPTH,
23+
currentDepth: number = 1
24+
): Page[] {
25+
const childrenOrder = pagesOrder.find((order) => isEqualIds(order.data.page, parentPageId));
1726

1827
/**
1928
* branch is a page children in tree
@@ -31,19 +40,19 @@ export function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder
3140
const unordered = pages.filter(page => isEqualIds(page._parent, parentPageId));
3241
const branch = Array.from(new Set([...ordered, ...unordered]));
3342

34-
/**
35-
* stop recursion when we got the passed max level
36-
*/
37-
if (currentLevel === level + 1) {
38-
return [];
39-
}
43+
const canRecurse = currentDepth < maxDepth;
4044

4145
/**
4246
* Each parents children can have subbranches
4347
*/
44-
return branch.filter(page => page && page._id).map(page => {
45-
return Object.assign({
46-
children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1),
47-
}, page.data);
48-
});
48+
return branch
49+
.filter((page) => page && page._id)
50+
.map((page) => {
51+
const subtree = canRecurse
52+
? createMenuTree(page._id!, pages, pagesOrder, maxDepth, currentDepth + 1)
53+
: [];
54+
55+
/** `children` must win over anything stored on the page document */
56+
return { ...page.data, children: subtree };
57+
});
4958
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{% set is_leaf = node.children is not defined or node.children is empty %}
2+
<section class="docs-sidebar__section{{ nested ? ' docs-sidebar__section--nested' : '' }}{{ is_leaf ? ' docs-sidebar__section--leaf' : '' }}" data-id="{{ node._id }}">
3+
<a class="docs-sidebar__section-title-wrapper"
4+
href="{{ node.uri ? '/' ~ node.uri : '/page/' ~ node._id }}"
5+
>
6+
<div class="docs-sidebar__section-title {{ page is defined and toString(page._id) == toString(node._id) ? 'docs-sidebar__section-title--active' : '' }}">
7+
<span>
8+
{{ node.title | striptags }}
9+
</span>
10+
{% if node.children is defined and node.children is not empty %}
11+
<button type="button" class="docs-sidebar__section-toggler" aria-label="Toggle section">
12+
{{ svg('arrow-up') }}
13+
</button>
14+
{% endif %}
15+
</div>
16+
</a>
17+
{% if node.children is defined and node.children is not empty %}
18+
<ul class="docs-sidebar__section-list docs-sidebar__section-list--nested">
19+
{% for child in node.children %}
20+
<li>
21+
{% include 'components/sidebar-section.twig' with { node: child, nested: true } %}
22+
</li>
23+
{% endfor %}
24+
</ul>
25+
{% endif %}
26+
</section>

src/backend/views/components/sidebar.twig

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,37 +10,7 @@
1010
<input class="docs-sidebar__search" type="text" placeholder="Search" />
1111
</span>
1212
{% for firstLevelPage in menu %}
13-
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
14-
<a class="docs-sidebar__section-title-wrapper"
15-
href="{{firstLevelPage.uri ? '/' ~ firstLevelPage.uri : '/page/' ~ firstLevelPage._id }}"
16-
>
17-
<div class="docs-sidebar__section-title {{page is defined and page._id == firstLevelPage._id ? 'docs-sidebar__section-title--active' : ''}}">
18-
<span>
19-
{{ firstLevelPage.title | striptags }}
20-
</span>
21-
{% if firstLevelPage.children is not empty %}
22-
<button class="docs-sidebar__section-toggler">
23-
{{ svg('arrow-up') }}
24-
</button>
25-
{% endif %}
26-
</div>
27-
</a>
28-
{% if firstLevelPage.children is not empty %}
29-
<ul class="docs-sidebar__section-list">
30-
{% for child in firstLevelPage.children %}
31-
<li>
32-
<a
33-
class="docs-sidebar__section-list-item-wrapper"
34-
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
35-
<div class="docs-sidebar__section-list-item {{page is defined and toString(page._id) == toString(child._id) ? 'docs-sidebar__section-list-item--active' : ''}}">
36-
<span>{{ child.title | striptags }}</span>
37-
</div>
38-
</a>
39-
</li>
40-
{% endfor %}
41-
</ul>
42-
{% endif %}
43-
</section>
13+
{% include 'components/sidebar-section.twig' with { node: firstLevelPage, nested: false } %}
4414
{% endfor %}
4515

4616
<div class="docs-sidebar__logo">

0 commit comments

Comments
 (0)