Let's create a React File Manager Chapter XII: Progress Bars, Skeltons And Overlays
Hasan Zohdy
Posted on September 14, 2022
We can now navigate between directories, but we don't have any feedback when we are loading the files, so let's add a loading progress bar.
Updating Root Path
We forgot to update file manager root path when we load file manager, so let's do it now.
// FileManager.tsx
// load the given directory path
const load = useCallback(
(path: string, isRoot = false) => {
setIsLoading(true);
+ if (isRoot) {
+ fileManager.setRootPath(path);
+ }
fileManager.load(path).then(node => {
setCurrentDirectoryNode(node);
setIsLoading(false);
if (isRoot) {
setRootDirectoryNode(node);
}
});
},
[fileManager],
);
Removing Modal
So modal is good to display file manager in isolated layout, but what if we need to make it take the entire page, thus we need to remove the modal, and we can make another wrapper component for modal.
// FileManager.tsx
import { Grid } from "@mantine/core";
import BaseFileManager from "app/file-manager/utils/FileManager";
import { useCallback, useEffect, useRef, useState } from "react";
import Content from "../../Content";
import FileManagerContext from "../../contexts/FileManagerContext";
import { Node } from "../../types/FileManager.types";
import { BodyWrapper } from "./FileManager.styles";
import { FileManagerProps } from "./FileManager.types";
import LoadingProgressBar from "./LoadingProgressBar";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";
export default function FileManager({ rootPath }: FileManagerProps) {
const [isLoading, setIsLoading] = useState(true);
const [currentDirectoryNode, setCurrentDirectoryNode] = useState<Node>();
const [rootDirectoryNode, setRootDirectoryNode] = useState<Node>();
const { current: fileManager } = useRef(new BaseFileManager());
// load the given directory path
const load = useCallback(
(path: string, isRoot = false) => {
setIsLoading(true);
if (isRoot) {
fileManager.setRootPath(path);
}
fileManager.load(path).then(node => {
setCurrentDirectoryNode(node);
setIsLoading(false);
if (isRoot) {
setRootDirectoryNode(node);
}
});
},
[fileManager],
);
// load root directory
useEffect(() => {
if (!rootPath) return;
load(rootPath, true);
}, [rootPath, fileManager, load]);
return (
<FileManagerContext.Provider value={fileManager}>
<LoadingProgressBar />
<Toolbar />
<BodyWrapper>
<Grid>
<Grid.Col span={3}>
<Sidebar rootDirectory={rootDirectoryNode} />
</Grid.Col>
<Grid.Col span={9}>
<Content />
</Grid.Col>
</Grid>
</BodyWrapper>
</FileManagerContext.Provider>
);
}
FileManager.defaultProps = {
rootPath: "/",
};
Don't forget to update the props types as well.
// FileManager.types.ts
import { Node } from "../../types/FileManager.types";
export type FileManagerProps = {
/**
* Root path to open in the file manager
*
* @default "/"
*/
rootPath?: string;
/**
* Callback for when a file/directory is selected
*/
onSelect?: (node: Node) => void;
/**
* Callback for when a file/directory is double clicked
*/
onDoubleClick?: (node: Node) => void;
/**
* Callback for when a file/directory is right clicked
*/
onRightClick?: (node: Node) => void;
/**
* Callback for when a file/directory is copied
*/
onCopy?: (node: Node) => void;
/**
* Callback for when a file/directory is cut
*/
onCut?: (node: Node) => void;
/**
* Callback for when a file/directory is pasted
* The old node will contain the old path and the new node will contain the new path
*/
onPaste?: (node: Node, oldNode: Node) => void;
/**
* Callback for when a file/directory is deleted
*/
onDelete?: (node: Node) => void;
/**
* Callback for when a file/directory is renamed
* The old node will contain the old path/name and the new node will contain the new path/name
*/
onRename?: (node: Node, oldNode: Node) => void;
/**
* Callback for when a directory is created
*/
onCreateDirectory?: (directory: Node) => void;
/**
* Callback for when file(s) is uploaded
*/
onUpload?: (files: Node[]) => void;
/**
* Callback for when a file is downloaded
*/
onDownload?: (node: Node) => void;
};
Now let's clean our home page and just render the file manager.
// HomePage.tsx
import Helmet from "@mongez/react-helmet";
import FileManager from "app/file-manager/components/FileManager";
export default function HomePage() {
return (
<>
<Helmet title="home" appendAppName={false} />
<FileManager />
</>
);
}
Adding Progress Bar Component
As we mentioned before, we're going to use events as it's super powerful, so we can use it to listen when file manager is loading then we show the progress bar, once loading is done we hide it.
Create components/LoadingProgressBar.tsx
file and add the following code:
// LoadingProgressBar.tsx
import useFileManager from "../../hooks/useFileManager";
export default function LoadingProgressBar() {
const fileManager = useFileManager();
return <div>LoadingProgressBar</div>;
}
Nothing fancy here, we just need to get the file manager instance to listen to its events.
Now let's import it in our File Manager component and add it to the body.
// FileManager.tsx
import { Grid, Modal } from "@mantine/core";
import BaseFileManager from "app/file-manager/utils/FileManager";
import { useCallback, useEffect, useRef, useState } from "react";
import Content from "../../Content";
import FileManagerContext from "../../contexts/FileManagerContext";
import { Node } from "../../types/FileManager.types";
import { BodyWrapper } from "./FileManager.styles";
import { FileManagerProps } from "./FileManager.types";
import LoadingProgressBar from "./LoadingProgressBar";
import Sidebar from "./Sidebar";
import Toolbar from "./Toolbar";
export default function FileManager({
open,
onClose,
rootPath,
}: FileManagerProps) {
const [isLoading, setIsLoading] = useState(true);
const [currentDirectoryNode, setCurrentDirectoryNode] = useState<Node>();
const [rootDirectoryNode, setRootDirectoryNode] = useState<Node>();
const { current: fileManager } = useRef(new BaseFileManager());
// load the given directory path
const load = useCallback(
(path: string, isRoot = false) => {
setIsLoading(true);
if (isRoot) {
fileManager.setRootPath(path);
}
fileManager.load(path).then(node => {
setCurrentDirectoryNode(node);
setIsLoading(false);
if (isRoot) {
setRootDirectoryNode(node);
}
});
},
[fileManager],
);
// load root directory
useEffect(() => {
if (!rootPath || !open) return;
load(rootPath, true);
}, [rootPath, fileManager, open, load]);
return (
<FileManagerContext.Provider value={fileManager}>
<Modal size="xl" opened={open} onClose={onClose}>
<LoadingProgressBar />
<Toolbar />
<BodyWrapper>
<Grid>
<Grid.Col span={3}>
<Sidebar rootDirectory={rootDirectoryNode} />
</Grid.Col>
<Grid.Col span={9}>
<Content />
</Grid.Col>
</Grid>
</BodyWrapper>
</Modal>
</FileManagerContext.Provider>
);
}
FileManager.defaultProps = {
rootPath: "/",
};
Sometimes i paste the entire component code, and others i don't so you can see the changes, but you can always check the full code in the github repo.
Now let's use Mantine Progress Bar and try it
// LoadingProgressBar.tsx
import { Progress } from "@mantine/core";
import useFileManager from "../../hooks/useFileManager";
export default function LoadingProgressBar() {
const fileManager = useFileManager();
return <Progress size="lg" value={50} striped animate />;
}
It should look like
Now let's add the logic to show/hide the progress bar.
// LoadingProgressBar.tsx
import { Progress } from "@mantine/core";
import { useEffect, useState } from "react";
import useFileManager from "../../hooks/useFileManager";
export default function LoadingProgressBar() {
const fileManager = useFileManager();
const [progress, setProgress] = useState(0);
useEffect(() => {
// let's create an interval that will update progress every 300ms
let interval: ReturnType<typeof setInterval>;
// we'll listen for loading state
const loadingEvent = fileManager.on("loading", () => {
setProgress(5);
interval = setInterval(() => {
// we'll increase it by 10% every 100ms
// if it's more than 100% we'll set it to 100%
setProgress(progress => {
if (progress >= 100) {
clearInterval(interval);
return 100;
}
return progress + 2;
});
}, 100);
});
// now let's listen when the loading is finished
const loadEvent = fileManager.on("load", () => {
// clear the interval
setProgress(100);
setTimeout(() => {
clearInterval(interval);
// set progress to 0
setProgress(0);
}, 300);
});
// unsubscribe events on unmount or when use effect dependencies change
return () => {
loadingEvent.unsubscribe();
loadEvent.unsubscribe();
};
}, [fileManager]);
if (progress === 0) return null;
return <Progress size="lg" value={progress} striped animate />;
}
The code looks a bit complicated, but it's not that hard, we just create an interval that will increase the progress by 10% every 100ms, and we'll listen to loading
and load
events to start and stop the interval.
And when the effect is unmounted or dependencies change we'll unsubscribe the events.
Now to test it, we'll fake the loading by adding a setTimeout
in our list
function.
// 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 => {
setTimeout(() => {
resolve({
data: {
node: fetchNode(directoryPath),
},
});
}, 3000);
});
}
}
Now the progress bar should look like
The sidebar is hidden, let's add Skelton to it.
First we declare a loading state, then we'll listen for loading events.
// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useEffect, useMemo, useState } from "react";
import useFileManager from "../../../hooks/useFileManager";
import { Node } from "../../../types/FileManager.types";
import SidebarNode from "./SidebarNode";
export type SidebarProps = {
rootDirectory?: Node;
};
export default function Sidebar({ rootDirectory }: SidebarProps) {
const rootChildren = useMemo(() => {
return rootDirectory?.children?.filter(child => child.isDirectory);
}, [rootDirectory]);
const fileManager = useFileManager();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadingEvent = fileManager.on("loading", () => setIsLoading(true));
const loadEvent = fileManager.on("load", () => setIsLoading(false));
return () => {
loadingEvent.unsubscribe();
loadEvent.unsubscribe();
};
}, [fileManager]);
if (isLoading) {
return (
<Card shadow={"sm"}>
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
</Card>
);
}
if (!rootDirectory) return null;
return (
<>
<Card shadow="sm">
<SidebarNode
node={rootDirectory}
navProps={{
p: 0,
}}
icon={<IconHome2 size={16} color="#78a136" />}
/>
{rootChildren?.map(child => (
<SidebarNode
navProps={{
p: 0,
pl: 10,
}}
key={child.path}
icon={<IconFolder size={16} fill="#31caf9" />}
node={child}
/>
))}
</Card>
</>
);
}
Pretty neat, right?
Now let's jump to the content part.
Content Loading State
We'll add a loading state to the content part, and we'll show a Overlay when the content is loading.
As previous in sidebar, we'll add loading state and listen for loading events, we can just copy/paste the code xD.
// Content.tsx
import { Card } from "@mantine/core";
import { useEffect, useState } from "react";
import useFileManager from "../hooks/useFileManager";
export default function Content() {
const fileManager = useFileManager();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadingEvent = fileManager.on("loading", () => setIsLoading(true));
const loadEvent = fileManager.on("load", () => setIsLoading(false));
return () => {
loadingEvent.unsubscribe();
loadEvent.unsubscribe();
};
}, [fileManager]);
return (
<>
<Card shadow="sm">
<div>Content</div>
</Card>
</>
);
}
If we notice, there is a pattern here, we're looking for a loading state, and we're listening for loading events, so we can create a custom hook to handle this.
Let's create a hooks/useLoading
hook.
// hooks/useLoading.ts
import { useEffect, useState } from "react";
import useFileManager from "./useFileManager";
export default function useLoading(): boolean {
const fileManager = useFileManager();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadingEvent = fileManager.on("loading", () => setIsLoading(true));
const loadEvent = fileManager.on("load", () => setIsLoading(false));
return () => {
loadingEvent.unsubscribe();
loadEvent.unsubscribe();
};
}, [fileManager]);
return isLoading;
}
Now let's use it in Content.tsx
and Sidebar.tsx
.
// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useMemo } from "react";
import useLoading from "../../../hooks/useLoading";
import { Node } from "../../../types/FileManager.types";
import SidebarNode from "./SidebarNode";
export type SidebarProps = {
rootDirectory?: Node;
};
export default function Sidebar({ rootDirectory }: SidebarProps) {
const rootChildren = useMemo(() => {
return rootDirectory?.children?.filter(child => child.isDirectory);
}, [rootDirectory]);
const isLoading = useLoading();
if (isLoading) {
return (
<Card shadow={"sm"}>
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
</Card>
);
}
if (!rootDirectory) return null;
...
Same in Content.tsx
.
// Content.tsx
import { Card } from "@mantine/core";
import useLoading from "../hooks/useLoading";
export default function Content() {
const isLoading = useLoading();
return (
<>
<Card shadow="sm">
<div>Content</div>
</Card>
</>
);
}
Now let's create our overlay, but first we need to make a wrapper for the content, so we can position the overlay.
create a Content.styles.tsx
file and add the following code.
// Content.styles.tsx
import styled from "@emotion/styled";
export const ContentWrapper = styled.div`
label: ContentWrapper;
position: relative;
`;
Now we import it
// Content.tsx
import { Card } from "@mantine/core";
import useLoading from "../hooks/useLoading";
import { ContentWrapper } from "./Content.styles";
export default function Content() {
const isLoading = useLoading();
return (
<>
<Card shadow="sm">
<ContentWrapper>Content</ContentWrapper>
</Card>
</>
);
}
As content height is small, let's set a height, and add a overflow: auto
to the content wrapper.
// Content.styles.tsx
import styled from "@emotion/styled";
export const ContentWrapper = styled.div`
label: ContentWrapper;
position: relative;
height: 300px;
overflow: auto;
`;
Let's also create a SidebarWrapper
and add a overflow: auto
to it.
// Sidebar.styles.tsx
import styled from "@emotion/styled";
export const SidebarWrapper = styled.div`
label: SidebarWrapper;
overflow: auto;
height: 300px;
position: relative;
`;
Let's inject it in both Cards, the one in the loading and the other for the content.
// Sidebar.tsx
import { Card, Skeleton } from "@mantine/core";
import { IconFolder, IconHome2 } from "@tabler/icons";
import { useMemo } from "react";
import useLoading from "../../../hooks/useLoading";
import { Node } from "../../../types/FileManager.types";
import { SidebarWrapper } from "./Sidebar.styles";
import SidebarNode from "./SidebarNode";
export type SidebarProps = {
rootDirectory?: Node;
};
export default function Sidebar({ rootDirectory }: SidebarProps) {
const rootChildren = useMemo(() => {
return rootDirectory?.children?.filter(child => child.isDirectory);
}, [rootDirectory]);
const isLoading = useLoading();
if (isLoading) {
return (
<Card shadow={"sm"}>
<SidebarWrapper>
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
<Skeleton height={8} mt={6} radius="xl" />
<Skeleton height={12} mt={6} width="80%" radius="sm" />
<Skeleton height={8} mt={6} width="60%" radius="xl" />
</SidebarWrapper>
</Card>
);
}
if (!rootDirectory) return null;
return (
<Card shadow="sm">
<SidebarWrapper>
<SidebarNode
node={rootDirectory}
navProps={{
p: 0,
}}
icon={<IconHome2 size={16} color="#78a136" />}
/>
{rootChildren?.map(child => (
<SidebarNode
navProps={{
p: 0,
pl: 10,
}}
key={child.path}
icon={<IconFolder size={16} fill="#31caf9" />}
node={child}
/>
))}
</SidebarWrapper>
</Card>
);
}
Now it looks like this:
Back to Content Component, let's add the overlay.
// Content.tsx
import { Card, LoadingOverlay } from "@mantine/core";
import useLoading from "../hooks/useLoading";
import { ContentWrapper } from "./Content.styles";
export default function Content() {
const isLoading = useLoading();
return (
<>
<Card shadow="sm">
<ContentWrapper>
<LoadingOverlay visible={isLoading} overlayBlur={2} />
</ContentWrapper>
</Card>
</>
);
}
And our final look is:
We're done with loaders.
And we're good now with our progress, next chapter we'll make a stop and clean up our code, also we'll reorganize our files and structure.
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.