Let's create a React File Manager Chapter XII: Progress Bars, Skeltons And Overlays

hassanzohdy

Hasan Zohdy

Posted on September 14, 2022

Let's create a React File Manager Chapter XII: Progress Bars, Skeltons And Overlays

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],
  );
Enter fullscreen mode Exit fullscreen mode

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: "/",
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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: "/",
};
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

It should look like

Progress Bar

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the progress bar should look like

Progress Bar

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
...
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
`;
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
`;
Enter fullscreen mode Exit fullscreen mode

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;
`;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now it looks like this:

File Manager

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

And our final look is:

File Manager

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.

💖 💪 🙅 🚩
hassanzohdy
Hasan Zohdy

Posted on September 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related