Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
018795d
Add more metadata
mscwilson Aug 7, 2025
605f05c
Move topics selection to the left
mscwilson Aug 7, 2025
1082e4b
Add use case selection
mscwilson Aug 7, 2025
77c2233
Remove intro text
mscwilson Aug 7, 2025
d7bdee1
Add tech selection
mscwilson Aug 7, 2025
58a6b77
Add Snowplow tech selection
mscwilson Aug 7, 2025
fa88fac
Grey out unavailable options
mscwilson Aug 7, 2025
503669b
Move topics to bottom of list
mscwilson Aug 7, 2025
b5166ce
Update search bar placeholder text
mscwilson Aug 7, 2025
109d684
Move topics filter up
mscwilson Aug 11, 2025
ce49b1d
Add use case label to tiles
mscwilson Aug 11, 2025
abebef5
Refactor filters
mscwilson Aug 11, 2025
18bb45f
Refactor to add filters hook
mscwilson Aug 11, 2025
a94e471
Refactor filters stuff to separate file
mscwilson Aug 11, 2025
9621ccc
Add web performance tutorial
mscwilson Aug 7, 2025
cab24c9
Add consent tutorial
mscwilson Aug 7, 2025
8e113fc
Add ML conversion scoring tutorial
mscwilson Aug 7, 2025
921f510
Add ecommerce tutorial
mscwilson Aug 7, 2025
cbf6826
Add hybrid tutorial
mscwilson Aug 7, 2025
04ab468
Add media player tutorial
mscwilson Aug 7, 2025
cc0770c
Add fractribution modelling tutorial
mscwilson Aug 7, 2025
9de671d
Add web tutorial
mscwilson Aug 7, 2025
37cd838
Add mobile tutorial
mscwilson Aug 7, 2025
1e2f176
Add extended metadata
mscwilson Aug 7, 2025
9987c18
Remove ampersands
mscwilson Aug 7, 2025
d3044fc
Update one link
mscwilson Aug 7, 2025
f7917e7
Replace web with unified in web accelerator
mscwilson Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 445 additions & 0 deletions src/components/tutorials/TutorialList/filters.tsx

Large diffs are not rendered by default.

311 changes: 143 additions & 168 deletions src/components/tutorials/TutorialList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,74 @@ import {
Box,
Grid,
InputAdornment,
MenuItem,
useMediaQuery,
useTheme,
} from '@mui/material'

import { getMetaData, getSteps } from '../utils'
import { getSteps } from '../utils'
import {
SearchBarFormControl,
SearchBarInput,
SnowplowPurpleSearchIcon,
TopicFilterFormControl,
TopicFilterSelect,
TutorialCardTitle,
Grid as TutorialGrid,
TopicFilterSidebar,
} from './styledComponents'
import { Meta, Topic as TopicType, Tutorial } from '../models'
import { Meta, Tutorial } from '../models'
import { Card, Description, StartButton, Topic } from './styledComponents'
import {
useTutorialFilters,
UseCaseFilter,
TopicFilter,
TechnologyFilter,
SnowplowTechFilter,
} from './filters'

function getParsedTutorials(tutorials: Meta[]): Tutorial[] {
return Object.values(tutorials).map((metaJson) => {
const meta = Meta.parse(metaJson)
const steps = getSteps(meta.id)
const tutorial = { meta, steps }
const parsedTutorials = Tutorial.parse(tutorial)

// Ensure no duplicate positions
const duplicates = new Set<number>()
for (const step of parsedTutorials.steps) {
if (duplicates.has(step.position)) {
throw new Error(
`Duplicate step position ${step.position} in tutorial "${parsedTutorials.meta.id}"` +
`\nCheck steps: \n${parsedTutorials.steps
.filter((s) => s.position === step.position)
.map((s) => s.path)
.join('\n')}\n`
)
}
duplicates.add(step.position)
}

return parsedTutorials
})
}

const SearchBar: FC<{
setSearch: React.Dispatch<React.SetStateAction<string>>
}> = ({ setSearch }) => {
return (
<Grid item>
<SearchBarFormControl variant="outlined">
<SearchBarInput
startAdornment={
<InputAdornment position="start">
<SnowplowPurpleSearchIcon />
</InputAdornment>
}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by tutorial name"
/>
</SearchBarFormControl>
</Grid>
)
}

function getFirstStepPath(meta: Meta): string | null {
const steps = getSteps(meta.id)
Expand Down Expand Up @@ -62,7 +113,12 @@ const TutorialCard: FC<{ tutorial: Tutorial }> = ({ tutorial }) => {
)}
</Grid>
<Grid item>
<Topic label={tutorial.meta.label} sx={{ mb: 2 }}></Topic>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{tutorial.meta.useCases.length > 0 && (
<Topic label={tutorial.meta.useCases[0]}></Topic>
)}
<Topic label={tutorial.meta.label}></Topic>
</Box>
</Grid>
<Grid item>
<Description>{tutorial.meta.description}</Description>
Expand All @@ -85,201 +141,120 @@ const TutorialCard: FC<{ tutorial: Tutorial }> = ({ tutorial }) => {
)
}

function searchFilter(term: string, tutorial?: Tutorial): boolean {
return tutorial
? tutorial?.meta.title.toLowerCase().includes(term.toLowerCase()) ||
tutorial?.meta.description.toLowerCase().includes(term.toLowerCase())
: false
}

function topicFilter(topic: TopicDropdown, tutorial?: Tutorial): boolean {
if (!tutorial) return false
if (topic === 'All topics') return true
return tutorial ? tutorial?.meta.label === topic : false
}

type TopicDropdown = keyof typeof TopicType.Values | 'All topics'
const TopicDropdownValues: TopicDropdown[] = [
'All topics',
...Object.values(TopicType.Values),
]

function getParsedTutorials(tutorials: Meta[]): Tutorial[] {
return Object.values(tutorials).map((metaJson) => {
const meta = Meta.parse(metaJson)
const steps = getSteps(meta.id)
const tutorial = { meta, steps }
const parsedTutorials = Tutorial.parse(tutorial)

// Ensure no duplicate positions
const duplicates = new Set<number>()
for (const step of parsedTutorials.steps) {
if (duplicates.has(step.position)) {
throw new Error(
`Duplicate step position ${step.position} in tutorial "${parsedTutorials.meta.id}"` +
`\nCheck steps: \n${parsedTutorials.steps
.filter((s) => s.position === step.position)
.map((s) => s.path)
.join('\n')}\n`
)
}
duplicates.add(step.position)
}
// Shared filter and tutorial content hook
const useTutorialContent = () => {
const {
setSearch,
selectedTopics,
setSelectedTopics,
selectedUseCases,
setSelectedUseCases,
allAvailableUseCases,
selectedTechnologies,
setSelectedTechnologies,
allAvailableTechnologies,
selectedSnowplowTech,
setSelectedSnowplowTech,
allAvailableSnowplowTech,
filteredAvailableOptions,
filteredTutorials,
} = useTutorialFilters(getParsedTutorials)

return parsedTutorials
})
return {
filters: (
<>
<SearchBar setSearch={setSearch} />
<UseCaseFilter
selectedUseCases={selectedUseCases}
setSelectedUseCases={setSelectedUseCases}
allAvailableUseCases={allAvailableUseCases}
availableUseCases={filteredAvailableOptions.availableUseCases}
/>
<TopicFilter
selectedTopics={selectedTopics}
setSelectedTopics={setSelectedTopics}
availableTopics={filteredAvailableOptions.availableTopics}
/>
<TechnologyFilter
selectedTechnologies={selectedTechnologies}
setSelectedTechnologies={setSelectedTechnologies}
allAvailableTechnologies={allAvailableTechnologies}
availableTechnologies={filteredAvailableOptions.availableTechnologies}
/>
<SnowplowTechFilter
selectedSnowplowTech={selectedSnowplowTech}
setSelectedSnowplowTech={setSelectedSnowplowTech}
allAvailableSnowplowTech={allAvailableSnowplowTech}
availableSnowplowTech={filteredAvailableOptions.availableSnowplowTech}
/>
</>
),
tutorials: filteredTutorials.map((tutorial: Tutorial) => (
<Grid item key={tutorial.meta.id}>
<TutorialCard tutorial={tutorial} />
</Grid>
)),
}
}

const TutorialList: FC = () => {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))

const [search, setSearch] = useState('')
const [topic, setTopic] = useState<TopicDropdown>('All topics')
const parsedTutorials = useMemo<Tutorial[]>(
() => getParsedTutorials(getMetaData()),
[]
)
const tutorials = useMemo<Tutorial[]>(
() => filterTutorials(search, topic, parsedTutorials),
[search, topic, parsedTutorials]
)
const content = useTutorialContent()

return (
<>
<Head>
<title>Tutorials | Snowplow Documentation</title>
</Head>{' '}
</Head>
{isMobile ? (
<MobileTutorialList
setSearch={setSearch}
topic={topic}
setTopic={setTopic}
tutorials={tutorials}
<MobileTutorialLayout
filters={content.filters}
tutorials={content.tutorials}
/>
) : (
<DesktopTutorialList
setSearch={setSearch}
topic={topic}
setTopic={setTopic}
tutorials={tutorials}
<DesktopTutorialLayout
filters={content.filters}
tutorials={content.tutorials}
/>
)}
</>
)
}

function filterTutorials(
search: string,
topic: TopicDropdown,
tutorials: Tutorial[]
): Tutorial[] {
return tutorials
.filter((tutorial) => searchFilter(search, tutorial))
.filter((tutorial) => topicFilter(topic, tutorial))
}

const IntroductionText: FC = () => {
return (
<div>
<p>
Solution accelerators are advanced tutorials that guide you through use
cases combining Snowplow with other tools.
</p>
</div>
)
}

const MobileTutorialList: FC<{
setSearch: React.Dispatch<React.SetStateAction<string>>
topic: TopicDropdown
setTopic: React.Dispatch<React.SetStateAction<TopicDropdown>>
tutorials: Tutorial[]
}> = ({ setSearch, topic, setTopic, tutorials }) => {
const MobileTutorialLayout: FC<{
filters: React.ReactNode
tutorials: React.ReactNode[]
}> = ({ filters, tutorials }) => {
return (
<Box sx={{ mt: 1 }}>
<Grid container direction="column" rowSpacing={2}>
<SearchBar setSearch={setSearch} />
<TopicFilter topic={topic} setTopic={setTopic} />

<IntroductionText />

{tutorials.map((tutorial: Tutorial) => (
<Grid item key={tutorial.meta.id}>
<TutorialCard tutorial={tutorial} />
</Grid>
))}
{filters}
{tutorials}
</Grid>
</Box>
)
}

const DesktopTutorialList: FC<{
setSearch: React.Dispatch<React.SetStateAction<string>>
topic: TopicDropdown
setTopic: React.Dispatch<React.SetStateAction<TopicDropdown>>
tutorials: Tutorial[]
}> = ({ setSearch, topic, setTopic, tutorials }) => {
const DesktopTutorialLayout: FC<{
filters: React.ReactNode
tutorials: React.ReactNode[]
}> = ({ filters, tutorials }) => {
return (
<Box marginX={8} marginY={3} sx={{ minWidth: '90vw', mr: 0 }}>
<Grid container columnSpacing={2}>
<SearchBar setSearch={setSearch} />
<TopicFilter topic={topic} setTopic={setTopic} />
</Grid>

<IntroductionText />
<Grid container columnSpacing={4}>
{/* Left sidebar with filters */}
<Grid item xs={3}>
<TopicFilterSidebar>{filters}</TopicFilterSidebar>
</Grid>

<TutorialGrid mb={2}>
{tutorials.map((tutorial: Tutorial) => (
<Grid item key={tutorial.meta.id}>
<TutorialCard tutorial={tutorial} />
</Grid>
))}
</TutorialGrid>
{/* Main content area */}
<Grid item xs={9}>
<TutorialGrid mb={2}>{tutorials}</TutorialGrid>
</Grid>
</Grid>
</Box>
)
}

const SearchBar: FC<{
setSearch: React.Dispatch<React.SetStateAction<string>>
}> = ({ setSearch }) => {
return (
<Grid item>
<SearchBarFormControl variant="outlined">
<SearchBarInput
startAdornment={
<InputAdornment position="start">
<SnowplowPurpleSearchIcon />
</InputAdornment>
}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name of the tutorial or guide..."
/>
</SearchBarFormControl>
</Grid>
)
}

const TopicFilter: FC<{
topic: TopicDropdown
setTopic: React.Dispatch<React.SetStateAction<TopicDropdown>>
}> = ({ topic, setTopic }) => {
return (
<Grid item>
<TopicFilterFormControl variant="outlined">
<TopicFilterSelect
value={topic}
onChange={(e) => setTopic(e.target.value as TopicDropdown)}
>
{TopicDropdownValues.map((topic) => (
<MenuItem key={topic} value={topic}>
{topic}
</MenuItem>
))}
</TopicFilterSelect>
</TopicFilterFormControl>
</Grid>
)
}

export default TutorialList
Loading