Skip to the code
cf.

File tree view

The shop's website editor has a sidebar that shows its files. Build FileTree, a named export of App.jsx, which takes tree: an array of nodes. A node with a children array (even an empty one) is a folder, and its children are more nodes; any other node, { name }, is a file. Names are unique within a folder. The default export, App, is only for the Preview.

Draw each level as a <ul> with one <li> per node. In every level, folders come first, then files, and each group is in alphabetical order by name, ignoring upper and lower case (App.jsx comes before index.js).

A folder's <li> holds a <button> showing its name, with aria-expanded set to whether it's open. Folders start closed, and clicking the button opens or closes the folder. An open folder shows its contents as a <ul> inside its <li>, after the button; an open folder with nothing in it shows the text Empty there instead. A closed folder's contents aren't on the page. A file's <li> holds just its name, as text.

Don't change the tree you're given (it may be frozen). Each folder remembers whether it's open even when the tree changes around it: if a new folder appears before an open one, the open one stays open.

<FileTree tree={[{ name: "readme.md" }, { name: "src", children: [{ name: "index.js" }] }]} />
 
// draws, with src open:
<ul>
  <li>
    <button aria-expanded="true">src</button>
    <ul>
      <li>index.js</li>
    </ul>
  </li>
  <li>readme.md</li>
</ul>

What the tests check

  1. shows the top level with folders first, each group alphabetical
    const tree = [
      { name: "readme.md" },
      { name: "src", children: [{ name: "index.js" }] },
      { name: "assets", children: [{ name: "logo.svg" }] },
      { name: "package.json" },
    ];
    render(<FileTree tree={tree} />);
    const top = screen.getAllByRole("list")[0];
    expect([...top.children].map((item) => item.textContent)).toEqual(["assets", "src", "package.json", "readme.md"]);
    
  2. sorts names ignoring upper and lower case
    const tree = [
      { name: "notes.txt" },
      { name: "README.md" },
      { name: "app.js" },
      { name: "src", children: [] },
      { name: "Public", children: [] },
    ];
    render(<FileTree tree={tree} />);
    const top = screen.getAllByRole("list")[0];
    expect([...top.children].map((item) => item.textContent)).toEqual(["Public", "src", "app.js", "notes.txt", "README.md"]);
    
  3. shows folders as closed buttons, and files as plain text
    const tree = [
      { name: "readme.md" },
      { name: "src", children: [{ name: "index.js" }] },
    ];
    render(<FileTree tree={tree} />);
    expect(screen.getByRole("button", { name: "src" })).toHaveAttribute("aria-expanded", "false");
    expect(screen.queryByText("index.js")).not.toBeInTheDocument();
    expect(screen.getByText("readme.md")).toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "readme.md" })).not.toBeInTheDocument();
    
  4. opening a folder shows its contents, sorted, inside it
    const tree = [
      {
        name: "src",
        children: [
          { name: "index.js" },
          { name: "utils", children: [{ name: "format.js" }] },
          { name: "App.jsx" },
          { name: "components", children: [] },
        ],
      },
    ];
    render(<FileTree tree={tree} />);
    const button = screen.getByRole("button", { name: "src" });
    await user.click(button);
    expect(button).toHaveAttribute("aria-expanded", "true");
    const list = within(button.closest("li")).getByRole("list");
    expect([...list.children].map((item) => item.textContent)).toEqual(["components", "utils", "App.jsx", "index.js"]);
    
  5. clicking an open folder closes it
    const tree = [{ name: "src", children: [{ name: "index.js" }] }];
    render(<FileTree tree={tree} />);
    const button = screen.getByRole("button", { name: "src" });
    await user.click(button);
    await user.click(button);
    expect(button).toHaveAttribute("aria-expanded", "false");
    expect(screen.queryByText("index.js")).not.toBeInTheDocument();
    
  6. opens folders inside folders
    const tree = [
      { name: "src", children: [{ name: "index.js" }, { name: "utils", children: [{ name: "format.js" }] }] },
    ];
    render(<FileTree tree={tree} />);
    await user.click(screen.getByRole("button", { name: "src" }));
    await user.click(screen.getByRole("button", { name: "utils" }));
    const format = screen.getByText("format.js");
    expect(format.closest("li").parentElement.closest("li")).toBe(screen.getByRole("button", { name: "utils" }).closest("li"));
    expect(within(screen.getByRole("button", { name: "src" }).closest("li")).getByText("format.js")).toBe(format);
    
  7. an open empty folder says Empty
    const tree = [{ name: "src", children: [{ name: "components", children: [] }, { name: "index.js" }] }];
    render(<FileTree tree={tree} />);
    await user.click(screen.getByRole("button", { name: "src" }));
    const components = screen.getByRole("button", { name: "components" });
    expect(screen.queryByText("Empty")).not.toBeInTheDocument();
    await user.click(components);
    expect(within(components.closest("li")).getByText("Empty")).toBeInTheDocument();
    
  8. doesn't change the tree it's given
    const freeze = (nodes) =>
      Object.freeze(nodes.map((node) => Object.freeze(node.children ? { ...node, children: freeze(node.children) } : { ...node })));
    const tree = freeze([
      { name: "readme.md" },
      { name: "src", children: [{ name: "index.js" }, { name: "App.jsx" }] },
    ]);
    render(<FileTree tree={tree} />);
    await user.click(screen.getByRole("button", { name: "src" }));
    expect(screen.getByText("App.jsx")).toBeInTheDocument();
    expect(tree.map((node) => node.name)).toEqual(["readme.md", "src"]);
    
  9. an open folder stays open when a new folder appears before it
    const tree = [
      { name: "assets", children: [{ name: "logo.svg" }] },
      { name: "src", children: [{ name: "index.js" }] },
    ];
    const { rerender } = render(<FileTree tree={tree} />);
    await user.click(screen.getByRole("button", { name: "src" }));
    rerender(<FileTree tree={[{ name: "docs", children: [{ name: "guide.md" }] }, ...tree]} />);
    expect(screen.getByRole("button", { name: "src" })).toHaveAttribute("aria-expanded", "true");
    expect(screen.getByText("index.js")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "docs" })).toHaveAttribute("aria-expanded", "false");
    expect(screen.queryByText("guide.md")).not.toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).