Site Logo
All Articles
Next.js, Material-UI, Jest logos with code text
December 22, 2023

How I Built and Integrated Material-UI Tabs with Dynamic Data

The final example can be seeing on the home page, under portfolio.

The technologies used are:

  • Material-UI
  • Next.js
  • TypeScript
  • Jest

So, you have selected Material-UI (MUI) as a component’s library, and you want to use their Tabs component (MUI). The problem is you need to populate the tabs dynamically, and you don’t know how many items will be displayed, and the options will not always be there.

One approach may be to iterate through the data and generat the required tabs and tab panels. In my case, I am iterating through each portfolio data, and the data is structured as:

// portfolio data
[
  {
    id: ‘portfolio-item-id’,
    terms: {
      nodes: [
        {
          slug: ‘html-5’,
          name: ‘HTML 5’
        }
      ]
    };
    title: ‘Portfolio Title’,
    uri: ‘/portfolio-title’
  },
  //…
];

The terms of each portfolio item are the categories that the portfolio item fits in. For example, If I worked with HTML 5 in the project, the project would have HTML listed within the terms.nodes array, and in my case, I have to know all of the categories that the portfolio item belongs to. A portfolio item can have n terms, and a term can have n portfolio items, where n is greater than 1.

For the sake of the length of the article, I am going to focus on how I generated these tabs and tab panels and will abstract some of the remaining implementations.

Using React’s Context hook to Control the Tabs

This portion is unique to my portfolio since I needed it to be controlled by other components on the page. I could have added a wrapper component to solve for this, but I had some nested components, and I would have had to pass props at almost every level, A.K.A. Prop Drilling (Geeks for Geeks).

Using the useContext hook prevents this and allows for a better developer experience.

// page.tsx
export default async function Home() {
  const { data: homeData } = await getHomeData();
  
  return (
    <Layout>
      <HomepageContextHOC homeData={homeData}>
        <HomeBanner />
        <TopSkillsSection />
        <AboutSection />
        <PillOverlap id='career-progression'>
          <TimelineSection/>
        </PillOverlap>
        <ProjectsSection
          id={'portfolio'}
          title={'Portfolio'}
          indexTitle={'Filters'}
          homeData={homeData}
        />
      </HomepageContextHOC>
    </Layout>
  );
}

and here is what ProjectsSection looks like (for now).

// ProjectsSection.tsx
const ProjectsSection: React.FC = (props) => {
  const { homeData, ...remainingProps } = props;
  const { getPortfolioTab, setPortfolioTab } = useHomepageCtx(); // using context to control the tabs from other components.

  const projectsData = homeData.data.page.homeData.dynamicMasonry;

  const [isChanging, setIsChanging] = useState(false); // to be used for fading in/out components

  const [tabLabels, tabPanels] = populateFilters(projectsData, isChanging); // focusing on this line

  return (
    <ContentWithIndex
      {...remainingProps}
      asideIndex={}
      content={}
    />
  );
};

export default ProjectsSection;

Generating the Tabs and Tab Panels

The idea that popped in my head was to use an util to generate the tabs based on the terms that belong to each project. This util should:

  • Iterate through the array of objects and create a new object along the way.
  • The new object should have string keys of each term slug pointing to an object that contains an array of projects, tab/filter component, filter name/label, filter/tab number to be used for controlling the active tab and accessibility, and a tab panel component.
  • The util should return a two-dimensional array that contains an array of Tab components and an array of Tab Panels built from the project terms.
  • The new object should also contain an “all” key by default, where it will have all projects stored

Project Filter Type:

type ProjectFiltersType = {
  [slug: string]: {
    projects: ProjectsSectionType[];
    filter: ReactElement;
    name: string;
    filterNum?: number;
  };
}

I began by writing some tests for it, but before I do that, I had to build a quick mocked response:

//mocks.ts
const projectsData: ProjectsDataType[] = [
  {
    featuredImage: {
      node: {
        sourceUrl: '/favicon-32x32.png',
        altText: 'featured image',
        title: 'Featured Image'
      }
    },
    id: 'id-1',
    terms: {
      nodes: [
        {slug: 'css-3', name: 'CSS3'},
        {slug: 'gatsbyjs', name: 'GatsbyJS'},
        {slug: 'graph-ql', name: 'GraphQL'},
        {slug: 'html-5', name: 'HTML5'},
      ]
    },
    title: 'Headless Personal Website',
    uri: '/portfolio/headless-personal-website',
  },
  {
    featuredImage: {
      node: {
        sourceUrl: '/favicon-32x32.png',
        altText: 'other image',
        title: 'Other Image'
      }
    },
    id: 'id-2',
    terms: {
      nodes: [
        {slug: 'css-3', name: 'CSS3'},
        {slug: 'react-js', name: 'ReactJS'},
        {slug: 'word-press', name: 'WordPress'},
        {slug: 'html-5', name: 'HTML5'},
      ]
    },
    title: 'Visible',
    uri: '/portfolio/visible',
  },
];

Now, I’m ready to write some jest tests.

// util.test.js
import { projectsData } from './mocks';

describe('Util', () => {
  const mockedProjectData = {
    featuredImage: {
      node: {
        sourceUrl: '/favicon-32x32.png',
        altText: 'featured image',
        title: 'Featured Image'
      }
    },
    id: 'i-am-an-id',
    terms: {
      nodes: [
        {
          slug: 'html-5',
          name: 'HTML 5',
        },
      ],
    },
    title: 'Project Title',
    uri: '/',
  };

  describe('populateFilters', () => {
    const tabs = populateFilters(projectsData, false);

    it("should return a 2 dimensional array", () => {
      expect(tabs.length).toBe(2);
    });

    it("first element of the array should be an array of tab labels", () => {
      expect(tabs[0][0].props.label).toBe('All');
      expect(tabs[0][1].props.label).toBe('CSS3');
      expect(tabs[0][2].props.label).toBe('GatsbyJS');
    });

    it("second element should be an array of tab pannels", () => {
      expect(tabs[1][0].props.filterName).toBe('All');
      expect(tabs[1][1].props.filterName).toBe('CSS3');
      expect(tabs[1][2].props.filterName).toBe('GatsbyJS');
    });
  });
}

These currently fail but let me fix that!

I set a variable to an empty object that I’m going to fill with the ProjectFiltersType. While I’m at it, I also began iterating through the data, and add all projects to the “all” key.

// util.tsx
export const populateFilters = (
  projectData: ProjectDataType[],
  isChanging: boolean, // used to fade in/out
) => {

  const projectFilters: ProjectFiltersType = {};
  
  projectData.forEach(project => {
    mapProjectToFilter(projectFilters, 'all', 'All', project);

  });

  return [];
};

Now to write the mapProjectToFilter function.

  • It takes the projectFilters, slug, filterName, and project to add to the object.
  • This function mutates the projectFilters, object and adds the required keys and values.

Here are some test for it:


describe('Util', () => {
  //...
  describe('mapProjectToFilter', () => {
    let projectFilters;

    beforeEach(() => {
      projectFilters = {};
    });
    
    it("should create a key from the slug", () => {
      expect(projectFilters['html-5']).toBeUndefined;
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);

      expect(projectFilters['html-5']?.name).not.toBeUndefined;
    });

    it("should count the number of projects in the projects array, and set them as a key/value pair", () => {
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);
      
      expect(projectFilters['html-5'].filterNum).toBe(0);
    });

    it("should store the filter name under the slug key", () => {
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);

      expect(projectFilters['html-5'].name).toBe('HTML 5');
    });

    it("should store the project under the slug key in an array", () => {
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);

      expect(projectFilters['html-5'].projects[0]).toStrictEqual(mockedProjectData);
    });

    it("should create a Tab component and store it within the slug key", () => {
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);

      expect(projectFilters['html-5'].filter.props.label).toEqual('HTML 5');
    });

    it("should push new projects to the projects array under the slug", () => {
      const secondProject = {
        ...mockedProjectData,
        title: 'Second Project'
      };

      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', mockedProjectData);
      mapProjectToFilter(projectFilters, 'html-5', 'HTML 5', secondProject);

      expect(projectFilters['html-5'].projects[1].title).toEqual('Second Project');
    });
  });
  //...
}

and here is the code.

// util.tsx
export const mapProjectToFilter = (
  projectFilters: ProjectFiltersType, 
  slug: string, 
  name: string, 
  project: ProjectsSectionType
) => {
  if(projectFilters[slug]) return projectFilters[slug]?.projects.push(project);

  const filtersLength = Object.keys(projectFilters).length;

  projectFilters[slug] = {
    filter: (
      <Tab
        key={slug}
        label={name}
        value={filtersLength}
        id={`project-filter-${slug}`}
        aria-controls={`filter-${name}`}
        data-item={filtersLength}
      />
    ),
    name: name,
    projects: [project],
    filterNum: filtersLength
  };
};

Now I need to iterate through every terms.nodes and add those categories as keys, and populate projects to it. Once we have the projectFitlers populated, we can iterate through the keys, and create our two dimensional array with tabs and tabPanels. Here is the what that looks like on the populateFilters function

// util.tsx
export const populateFilters = (
  projectData: ProjectsSectionType[],
  isChanging: boolean,
) => {

  // ...
  
  projectData.forEach(project => {
    // ...

    project.terms.nodes.forEach(({ slug, name }: TermType) => {
      mapProjectToFilter(projectFilters, slug, name, project);
    });
  });

  const tabLabels: ReactElement[] = [];
  const tabPanels: ReactElement[] = [];
  
  Object.keys(projectFilters).sort().forEach((filterSlug) => {
    const projects = projectFilters[filterSlug].projects;
    const filterNum = projectFilters[filterSlug].filterNum;

    tabLabels.push(projectFilters[filterSlug].filter);

    tabPanels.push(
      <QuiltedImagesTab 
        key={filterSlug}
        projects={projects}
        value={String(filterNum)}
        filterName={projectFilters[filterSlug].name}
        isChanging={isChanging}
      />
    );
  });

  return [tabLabels, tabPanels];
};

Going back to the ProjectsSection component, I then used the extracted tabs and tab panels and added them as children to the Tabs component and TabContext component respectively.

// ProjectsSection.tsx
const ProjectsSection: React.FC< ProjectsSectionProps & ContentWithIndexProps > = (props) => {
  // ...

  const [tabLabels, tabPanels] = populateFilters(projectsData, isChanging); // focusing on this line

  const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
    setIsChanging(true);
    setTimeout(() => {
      setPortfolioTab(newValue);
    }, 300);
    setTimeout(() => {
      setIsChanging(false);
    }, 350);
  };

  return (
    <ContentWithIndex
      {...remainingProps}
      asideIndex={(
        <Tabs
          orientation='vertical'
          variant="scrollable"
          value={getPortfolioTab()}
          onChange={handleTabChange}
          aria-label="Filters for Projects"
          allowScrollButtonsMobile
        >
          {...tabLabels}
        </Tabs>
      )}
      content={(
        <TabContext value={String(getPortfolioTab())}>
          {...tabPanels}
        </TabContext>
      )}
    />
  );
};

export default ProjectsSection;

Conclusion

When I first wrote this data structure in 2022, I was creating too many arrays, and iterating through too many of them. While it did work, it wasn’t efficient space complexity wise. With this new approach, I streamlined most of the steps, reducing the space complexity.

If you would like to learn more about how to setup the Tabs, MUI has some great examples to go off, check them out (MUI Docs)!