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)!