Skip to content

Commit a002928

Browse files
committed
Merge branch 'double-tree'
2 parents 1f524cc + 6d2e037 commit a002928

File tree

1 file changed

+242
-0
lines changed

1 file changed

+242
-0
lines changed

src/DoubleTree.stories.tsx

+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { number, text } from '@storybook/addon-knobs';
2+
import { storiesOf } from '@storybook/react';
3+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
4+
5+
import { Tree, TreeContainer, TreeNode, TreeState, useTreeNodeController } from 'index';
6+
import { TreeSource } from 'types';
7+
import { useTreeController } from 'use-tree-controller';
8+
9+
/* tslint:disable:no-console */
10+
11+
// Generate strings 'a' through 'z'.
12+
function letterRange(): string[] {
13+
return range(('a').charCodeAt(0), ('z').charCodeAt(0)).map((x) => String.fromCharCode(x));
14+
}
15+
16+
// Generate an inclusive range.
17+
function range(start: number, end: number): number[] {
18+
return [...Array(end - start + 1)].map((_, i) => i + start);
19+
}
20+
21+
async function timeout(ms) {
22+
return new Promise((resolve) => { setTimeout(resolve, ms); });
23+
}
24+
25+
const testSource = {
26+
async children(id: string | null) {
27+
console.log('source load children', id);
28+
const parentId = id || '';
29+
await timeout(100 + Math.random() * 1000);
30+
return letterRange().map((x) => ({
31+
id: parentId + x,
32+
label: parentId + x,
33+
hasChildren: true,
34+
}));
35+
},
36+
async trail(id: string) {
37+
console.log('source load trail', id);
38+
await timeout(100 + Math.random() * 1000);
39+
return range(1, id.length).reverse().map((length) => {
40+
return {
41+
id: id.substr(0, length),
42+
label: id.substr(0, length),
43+
hasChildren: true,
44+
};
45+
});
46+
},
47+
};
48+
49+
interface Labeled {
50+
label: string;
51+
}
52+
53+
const stories = storiesOf('Double Tree', module);
54+
55+
const LeftList: React.FC<IListProps> = React.memo(({ tree }) => {
56+
return (
57+
<ul>
58+
{tree.isLoading ? <li>loading...</li> : null}
59+
{tree.items.map((item) => (
60+
<LeftListItem item={item} key={item.id} />
61+
))}
62+
</ul>
63+
);
64+
});
65+
66+
const LeftListItem: React.FC<IListItemProps> = React.memo(({ item }) => {
67+
const { updateState, setActiveId } = useTreeController();
68+
const isRightListEntryPoint = item.depth === 2 && item.hasChildren;
69+
const subItems = item.isExpanded && item.hasChildren && !isRightListEntryPoint
70+
? <LeftList tree={item.children} />
71+
: null;
72+
73+
const onClick = useCallback(() => {
74+
if (isRightListEntryPoint) {
75+
// Make this item active, which will update the tree on the right.
76+
setActiveId(item.id);
77+
} else {
78+
// Toggle this item and collapse everything else at this depth.
79+
updateState((state, rootTree) => {
80+
const { expandedIds, ...rest } = state;
81+
const newExpandedIds: { [k: string]: boolean } = {
82+
...expandedIds,
83+
// Collapse all children at this same depth.
84+
...Object.fromEntries(
85+
Object.values(rootTree.allNodes) // For all nodes in the tree.
86+
.filter(({ depth }) => depth === item.depth) // At the same level.
87+
.map(({ id }) => [id, false]), // Create a [id]: false element in expandedIds.
88+
),
89+
// Toggle the current item.
90+
[item.id]: !item.isExpanded,
91+
};
92+
return { expandedIds: newExpandedIds, ...rest };
93+
});
94+
}
95+
}, [item, isRightListEntryPoint, setActiveId, updateState]);
96+
return (
97+
<li>
98+
<a onClick={onClick}>
99+
{' '}
100+
{item.isActiveTrail
101+
? (item.isActive ? <strong>{item.label}</strong> : <em>{item.label}</em>)
102+
: item.label
103+
}
104+
{` (${item.depth})`}
105+
</a>
106+
{subItems}
107+
</li>
108+
);
109+
});
110+
111+
interface IListProps {
112+
tree: Tree<Labeled>;
113+
}
114+
115+
interface IListItemProps {
116+
item: TreeNode<Labeled>;
117+
}
118+
119+
const RightList: React.FC<IListProps> = React.memo(({ tree }) => {
120+
return (
121+
<ul>
122+
{tree.isLoading ? <li>loading...</li> : null}
123+
{tree.items.map((item) => (
124+
<RightListItem item={item} key={item.id} />
125+
))}
126+
</ul>
127+
);
128+
});
129+
130+
const RightListItem: React.FC<IListItemProps> = React.memo(({ item }) => {
131+
const { toggleExpanded } = useTreeNodeController(item);
132+
const subItems = item.isExpanded && item.hasChildren
133+
? <RightList tree={item.children} />
134+
: null;
135+
136+
return (
137+
<li>
138+
<button onClick={toggleExpanded}>
139+
{item.isExpanded ? '(-)' : '(+)'}
140+
</button>
141+
{' '}
142+
{item.isActiveTrail
143+
? (item.isActive ? <strong>{item.label}</strong> : <em>{item.label}</em>)
144+
: item.label
145+
}
146+
{` (${item.depth})`}
147+
{subItems}
148+
</li>
149+
);
150+
});
151+
152+
/**
153+
* Create a source that returns only items under a specific root item of a tree.
154+
*
155+
* @param source The source to wrap.
156+
* @param rootId The root ID within the source.
157+
*/
158+
function useSubTreeSource<T>(source: TreeSource<T>, rootId: string | null): TreeSource<T> {
159+
return useMemo(() => {
160+
return {
161+
async children(id: string | null) {
162+
if (id === null) {
163+
if (rootId === null) {
164+
return [];
165+
}
166+
return source.children(rootId);
167+
}
168+
return source.children(id);
169+
},
170+
async trail(id: string) {
171+
if (rootId === null) {
172+
return [];
173+
}
174+
const fullTrail = await source.trail(id);
175+
const rootItemIndex = fullTrail.findIndex((item) => item.id === rootId);
176+
if (rootItemIndex === -1) {
177+
return [];
178+
}
179+
return fullTrail.slice(0, rootItemIndex + 1);
180+
}
181+
}
182+
}, [source, rootId]);
183+
}
184+
185+
interface TreeExampleContainerProps {
186+
source: TreeSource<Labeled>;
187+
activeId?: string;
188+
loadingTransitionMs?: number;
189+
}
190+
191+
const TreeExampleContainer: React.FC<TreeExampleContainerProps> = (props) => {
192+
const { activeId, source, loadingTransitionMs = 0 } = props;
193+
const [leftState, setLeftState] = useState<TreeState>({ activeId });
194+
const [rightState, setRightState] = useState<TreeState>({});
195+
196+
// Switch active items based on Storybook knobs.
197+
useEffect(() => {
198+
setLeftState({ ...leftState, activeId });
199+
}, [activeId]);
200+
201+
const subSource = useSubTreeSource(source, leftState.activeId || null);
202+
203+
const currentActiveId = leftState.activeId;
204+
useEffect(() => {
205+
console.log(`The active ID has changed to ${currentActiveId}. We can update routing here.`);
206+
}, [currentActiveId]);
207+
208+
return (
209+
<div style={{ clear: 'both'}}>
210+
<div style={{float: 'left', width: '50%'}}>
211+
<TreeContainer
212+
source={source}
213+
state={leftState}
214+
onStateChange={setLeftState}
215+
rootElement={LeftList}
216+
loaderOptions={{loadingTransitionMs}}
217+
/>
218+
</div>
219+
<div style={{float: 'left', width: '50%'}}>
220+
<TreeContainer
221+
source={subSource}
222+
state={rightState}
223+
onStateChange={setRightState}
224+
rootElement={RightList}
225+
loaderOptions={{loadingTransitionMs}}
226+
/>
227+
</div>
228+
</div>
229+
);
230+
};
231+
232+
stories.add('Double tree', () => {
233+
return (
234+
<>
235+
<TreeExampleContainer
236+
source={testSource}
237+
activeId={text('Left active ID', 'seb')}
238+
loadingTransitionMs={number('Loading transition ms', 100)}
239+
/>
240+
</>
241+
);
242+
});

0 commit comments

Comments
 (0)