Let's create a React File Manager Chapter XI: Navigating between directories
Hasan Zohdy
Posted on September 14, 2022
So we can see proper results, we need to make a file to fetch data from, as pseudo data, we will use data.json
file, it will contain an array of directories and files, each directory will have an array of files and directories, and each file will have a name and a type, we will use this data to create our file manager.
Create utils/data.json
file and insert inside it the following code:
{
"path": "/",
"name": "/",
"size": 70533,
"isDirectory": true,
"children": [
{
"path": "/Library",
"name": "Library",
"size": 68201,
"isDirectory": true,
"children": [
{
"path": "/var/log/rhodium_lodge.lwp",
"name": "var",
"size": 87444,
"isDirectory": false
}
]
},
{
"path": "/media/intelligent.jnlp",
"name": "media",
"size": 1236,
"isDirectory": false
},
{
"path": "/var/log/fire_furthermore_purity.mp4v",
"name": "var",
"size": 95179,
"isDirectory": false
},
{
"path": "/local/log",
"name": "local",
"size": 9739,
"isDirectory": true,
"children": [
{
"path": "/opt",
"name": "opt",
"size": 68469,
"isDirectory": true,
"children": [
{
"path": "/usr/libdata/southwest.vtf",
"name": "usr",
"size": 75879,
"isDirectory": false
}
]
}
]
},
{
"path": "/etc/mail/bossy_fess_compress.mvb",
"name": "etc",
"size": 47549,
"isDirectory": false
},
{
"path": "/usr/bin/arkansas_suv.mus",
"name": "usr",
"size": 98329,
"isDirectory": false
},
{
"path": "/home/concept_alliance_account.avi",
"name": "home",
"size": 37505,
"isDirectory": false
},
{
"path": "/private/tmp",
"name": "private",
"size": 96386,
"isDirectory": true,
"children": [
{
"path": "/opt/bin/program_rupee_views.ifb",
"name": "opt",
"size": 36417,
"isDirectory": false
}
]
},
{
"path": "/home/qui_account_joule.srt",
"name": "home",
"size": 59250,
"isDirectory": false
},
{
"path": "/tmp",
"name": "tmp",
"size": 79276,
"isDirectory": true,
"children": [
{
"path": "/opt/share/east_beauty.sgl",
"name": "opt",
"size": 70401,
"isDirectory": false
}
]
},
{
"path": "/home",
"name": "home",
"size": 77920,
"isDirectory": true,
"children": [
{
"path": "/private/var",
"name": "private",
"size": 50803,
"isDirectory": true,
"children": [
{
"path": "/var/mail",
"name": "var",
"size": 51658,
"isDirectory": true,
"children": [
{
"path": "/Users",
"name": "Users",
"size": 66068,
"isDirectory": true,
"children": [
{
"path": "/media/bespoke_connecticut.wmd",
"name": "media",
"size": 92243,
"isDirectory": false
},
{
"path": "/usr/include",
"name": "usr",
"size": 59751,
"isDirectory": true,
"children": [
{
"path": "/usr/local/bin",
"name": "usr",
"size": 86273,
"isDirectory": true,
"children": [
{
"path": "/home/account.sc",
"name": "home",
"size": 28456,
"isDirectory": false
}
]
},
{
"path": "/lost+found",
"name": "lost+found",
"size": 95753,
"isDirectory": true,
"children": [
{
"path": "/net/invoice_connecting_kuwait.ssf",
"name": "net",
"size": 48547,
"isDirectory": false
}
]
}
]
}
]
}
]
},
{
"path": "/usr/bin/sdd_shoes_lead.ddf",
"name": "usr",
"size": 86112,
"isDirectory": false
}
]
},
{
"path": "/net/steel_paradigms_generate.geo",
"name": "net",
"size": 5962,
"isDirectory": false
}
]
},
{
"path": "/src",
"name": "src",
"size": 41634,
"isDirectory": true,
"children": [
{
"path": "/usr/libdata",
"name": "usr",
"size": 39378,
"isDirectory": true,
"children": [
{
"path": "/usr/lib",
"name": "usr",
"size": 20505,
"isDirectory": true,
"children": [
{
"path": "/root/aggregate.igm",
"name": "root",
"size": 65337,
"isDirectory": false
},
{
"path": "/home/user/fussy_integrate.m21",
"name": "home",
"size": 69135,
"isDirectory": false
}
]
},
{
"path": "/var/mail",
"name": "var",
"size": 15543,
"isDirectory": true,
"children": [
{
"path": "/etc/ppp/response_savings_southeast.xaml",
"name": "etc",
"size": 267,
"isDirectory": false
}
]
}
]
},
{
"path": "/sbin",
"name": "sbin",
"size": 88790,
"isDirectory": true,
"children": [
{
"path": "/etc/periodic",
"name": "etc",
"size": 23663,
"isDirectory": true,
"children": [
{
"path": "/media",
"name": "media",
"size": 81614,
"isDirectory": true,
"children": [
{
"path": "/Network/field.xer",
"name": "Network",
"size": 44406,
"isDirectory": false
},
{
"path": "/home/user/dir",
"name": "home",
"size": 89296,
"isDirectory": true,
"children": [
{
"path": "/selinux/orchestrator.xsm",
"name": "selinux",
"size": 84414,
"isDirectory": false
},
{
"path": "/media",
"name": "media",
"size": 68960,
"isDirectory": true,
"children": [
{
"path": "/usr/monstrous_moratorium_response.ttl",
"name": "usr",
"size": 57457,
"isDirectory": false
},
{
"path": "/usr/obj/movies.exr",
"name": "usr",
"size": 52956,
"isDirectory": false
}
]
}
]
}
]
},
{
"path": "/opt/include",
"name": "opt",
"size": 24011,
"isDirectory": true,
"children": [
{
"path": "/usr/libdata",
"name": "usr",
"size": 57283,
"isDirectory": true,
"children": [
{
"path": "/var/log/sievert_west_guilder.cdkey",
"name": "var",
"size": 36818,
"isDirectory": false
},
{
"path": "/usr/ports",
"name": "usr",
"size": 62616,
"isDirectory": true,
"children": [
{
"path": "/Applications/kyat_saturate_home.grv",
"name": "Applications",
"size": 21664,
"isDirectory": false
},
{
"path": "/root",
"name": "root",
"size": 10739,
"isDirectory": true,
"children": [
{
"path": "/private/var",
"name": "private",
"size": 90775,
"isDirectory": true,
"children": [
{
"path": "/Library",
"name": "Library",
"size": 85881,
"isDirectory": true,
"children": [
{
"path": "/mnt/cobalt_bicycle_earum.wsdl",
"name": "mnt",
"size": 55769,
"isDirectory": false
},
{
"path": "/etc",
"name": "etc",
"size": 8215,
"isDirectory": true,
"children": [
{
"path": "/usr/obj/virginia_manager.ait",
"name": "usr",
"size": 3988,
"isDirectory": false
}
]
}
]
}
]
},
{
"path": "/lost+found/deposit_auxiliary.evy",
"name": "lost+found",
"size": 14874,
"isDirectory": false
}
]
}
]
}
]
},
{
"path": "/net/exploit_wooden_lead.rif",
"name": "net",
"size": 64496,
"isDirectory": false
}
]
}
]
}
]
}
]
},
{
"path": "/usr/libdata",
"name": "usr",
"size": 76429,
"isDirectory": true,
"children": [
{
"path": "/etc/mail/riverside_associate.vst",
"name": "etc",
"size": 74947,
"isDirectory": false
}
]
},
{
"path": "/tmp/uphold_input.ma",
"name": "tmp",
"size": 5102,
"isDirectory": false
}
]
}
Now let's import it in our service and remove the old generators.
// file-manager-service.ts
- import { newDirectoryNode } from "../utils/data";
+ import rootNode from "./../utils/data.json";
/**
* {@inheritDoc}
*/
public list(directoryPath: string): Promise<any> {
return new Promise(resolve => {
resolve({
data: {
- node: newDirectoryNode(),
+ node: rootNode,
},
});
});
}
Now we can have stable nodes, but we need to make it more real, let's create a function that fetch the proper directory based on the given directory path.
Create fetchDirectory
function in utils/helpers.ts
file.
// helpers.ts
import { Node } from "../types/FileManager.types";
import rootNode from "./data.json";
export default function fetchNode(
directoryPath: string,
node?: Node,
): Node | undefined {
if (!node) {
node = rootNode;
}
if (node.path === directoryPath) {
return node;
} else if (node.isDirectory && node.children) {
for (const child of node.children) {
const foundNode = fetchNode(directoryPath, child);
if (foundNode) {
return foundNode;
}
}
}
}
A simple recursion function here to check if the given node is the one we are looking for, if not, we will check the children of the node and so on.
Let's go back to our service again and update its code.
// file-manager-service.ts
import FileManagerServiceInterface from "../types/FileManagerServiceInterface";
import fetchNode from "../utils/helpers";
export class FileManagerService implements FileManagerServiceInterface {
/**
* {@inheritDoc}
*/
public list(directoryPath: string): Promise<any> {
return new Promise(resolve => {
resolve({
data: {
node: fetchNode(directoryPath),
},
});
});
}
}
Before we move on
Again, as i mentioned in previous article, we're developing and scaling up our application as it will be more complicated over the next articles, but we're walking step by step so you know where we are now and where we're going.
Sidebar navigation
Now let's add the ability to navigate between directories, we will do that by adding a click handler to the sidebar node, when the user clicks on a node, we will set the current directory node to the clicked node.
// SidebarNode.tsx
return (
<>
<NavLink
{...navProps}
active={isActiveNode}
+ onClick={() => fileManager.load(node.path)}
label={
<>
<IconWrapper>{icon}</IconWrapper>
<span>{node.name}</span>
</>
}
/>
</>
);
Now we need to display a loader to tell that the file manager is loading a content, here we are going to introduce our secret weapon: Events
File Manager Events
So we need to know from the file manager when it is loading a directory, so we can display a loader, we can do that by adding an event to the file manager, so we can listen to it from the sidebar node.
// FileManager.types.tsx
/**
* File manager events
*/
export type FileManagerEvents = "loading" | "load" | "directoryChange";
loading
event will be triggered before loading directory.
load
event will be triggered after loading directory.
directoryChange
event will be triggered after changing current directory.
Now let's add an event listener in our file manager.
// FileManager.tsx
import events, { EventSubscription } from "@mongez/events";
...
/**
* Add event listener to the given event
*/
public on(event: FileManagerEvents, callback: any): EventSubscription {
return events.subscribe(`file-manger.${event}`, callback);
}
We're going to use Mongez Events to manage events.
Now let's add trigger method to trigger the event.
// FileManager.tsx
...
/**
* Trigger the given event
*/
public trigger(event: FileManagerEvents, ...args: any[]): void {
events.trigger(`file-manger.${event}`, ...args);
}
Now let's add trigger the events from load
method.
// FileManager.tsx
...
/**
* Load the given path
*/
public load(path: string): Promise<Node> {
// trigger loading event
this.trigger("loading");
return new Promise((resolve, reject) => {
fileManagerService
.list(path)
.then(response => {
// trigger load event as the directory has been loaded successfully.
this.trigger("load", response.data.node);
this.currentDirectoryPath = path;
this.currentDirectoryNode = response.data.node;
// if the current directory is not as the same loaded directory path,
// then we'll trigger directory changed event.
if (response.data.node.path !== this.currentDirectoryPath) {
this.trigger("directoryChange", this.currentDirectoryNode);
}
resolve(this.currentDirectoryNode as Node);
})
.catch(reject);
});
}
loading
event will be triggered before loading directory, and once the response is sent successfully to us, we'll trigger load
event, and if the current directory is not as the same loaded directory path, then we'll trigger directoryChange
event.
Listening to current directory change
Now let's add an event listener to current directory change in sidebar node, if the current directory is changed then update the active state.
// SidebarNode.tsx
...
useEffect(() => {
fileManager.on("directoryChange", (newCurrentDirectory: Node) => {
if (newCurrentDirectory.path !== node.path && isActiveNode) {
setIsActiveNode(false);
} else if (newCurrentDirectory.path === node.path && !isActiveNode) {
setIsActiveNode(true);
}
});
}, [fileManager, isActiveNode, node.path]);
So we have two cases here, first one is current node is active but new current directory node's path is not the same as the node path, then we'll set it to be false.
Second case the current node is not active but new current directory node's path is the same as the node path, then we'll set it to be true.
Now we'll remove the old useEffect
that was listening for fileManager.currentDirectoryNode
to not make any conflicts.
So the final sidebar node should look like
// SidebarNode.tsx
import { NavLink, NavLinkProps } from "@mantine/core";
import { IconFolder } from "@tabler/icons";
import { useEffect, useState } from "react";
import useFileManager from "../../../hooks/useFileManager";
import { Node } from "../../../types/FileManager.types";
import { IconWrapper } from "./SidebarNode.styles";
export type SidebarNodeProps = {
node: Node;
icon?: React.ReactNode;
navProps?: Partial<NavLinkProps>;
};
export default function SidebarNode({
icon,
node,
navProps = {},
}: SidebarNodeProps) {
const fileManager = useFileManager();
const [isActiveNode, setIsActiveNode] = useState(
node === fileManager.currentDirectoryNode,
);
useEffect(() => {
const event = fileManager.on(
"directoryChange",
(newCurrentDirectory: Node) => {
if (newCurrentDirectory.path !== node.path && isActiveNode) {
setIsActiveNode(false);
} else if (newCurrentDirectory.path === node.path && !isActiveNode) {
setIsActiveNode(true);
}
},
);
}, [fileManager, isActiveNode, node]);
return (
<>
<NavLink
{...navProps}
active={isActiveNode}
onClick={() => fileManager.load(node.path)}
label={
<>
<IconWrapper>{icon}</IconWrapper>
<span>{node.name}</span>
</>
}
/>
</>
);
}
SidebarNode.defaultProps = {
icon: <IconFolder fill="#31caf9" />,
};
We need to unsubscribe from the event listener when the component is unmounted or the effect dependencies are updated.
useEffect(() => {
const event = fileManager.on(
"directoryChange",
(newCurrentDirectory: Node) => {
if (newCurrentDirectory.path !== node.path && isActiveNode) {
setIsActiveNode(false);
} else if (newCurrentDirectory.path === node.path && !isActiveNode) {
setIsActiveNode(true);
}
},
);
+ return () => event.unsubscribe();
}, [fileManager, isActiveNode, node]);
Now we can successfully navigate between sidebar links.
In our next chapter, we will make progress bar to indicate loading progress.
The Power Of Events
If you noticed, we added an event to each sidebar node, but the catch here only the affected sidebar nodes will be re-rendered, so if we have 1000 nodes, and we click on one of them, only the affected node will be re-rendered, and the rest will be untouched.
Article Repository
You can see chapter files in Github Repository
Don't forget the
main
branch has the latest updated code.
Tell me where you are now
If you're following up with me this series, tell me where are you now and what you're struggling with, i'll try to help you as much as i can.
Salam.
Posted on September 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.