feat(templates): group hub index entries by section metadata

Add section/sectionGroup fields to hub workflow index schema to enable
category nav grouping on cloud. The mapper now groups entries by section
(e.g. "Image", "Video") under sectionGroup headers (e.g. "GENERATION TYPE")
instead of flattening everything into a single "All" category.

Falls back to flat "All" when BE does not yet provide section metadata.

TODO(hub-api): field names pending BE spec confirmation.
This commit is contained in:
dante01yoon
2026-04-15 18:44:56 +09:00
parent 0e7c4c1426
commit dcf4bb3534
5 changed files with 169 additions and 35 deletions

View File

@@ -108,21 +108,76 @@ describe('mapHubWorkflowIndexEntryToTemplate', () => {
})
describe('mapHubWorkflowIndexToCategories', () => {
it('wraps flat hub entries in a single default category', () => {
const result = mapHubWorkflowIndexToCategories(
[
makeEntry({ name: 'template-a', title: 'Template A' }),
makeEntry({ name: 'template-b', title: 'Template B' })
],
'All Templates'
)
it('groups entries by section and sectionGroup into WorkflowTemplates', () => {
const result = mapHubWorkflowIndexToCategories([
makeEntry({
name: 'img-template',
title: 'Image Template',
section: 'Image',
sectionGroup: 'GENERATION TYPE'
}),
makeEntry({
name: 'vid-template',
title: 'Video Template',
section: 'Video',
sectionGroup: 'GENERATION TYPE'
}),
makeEntry({
name: 'img-template-2',
title: 'Image Template 2',
section: 'Image',
sectionGroup: 'GENERATION TYPE'
})
])
expect(result).toHaveLength(2)
const imageCategory = result.find((c) => c.title === 'Image')!
expect(imageCategory.moduleName).toBe('default')
expect(imageCategory.category).toBe('GENERATION TYPE')
expect(imageCategory.templates.map((t) => t.name)).toEqual([
'img-template',
'img-template-2'
])
const videoCategory = result.find((c) => c.title === 'Video')!
expect(videoCategory.moduleName).toBe('default')
expect(videoCategory.category).toBe('GENERATION TYPE')
expect(videoCategory.templates.map((t) => t.name)).toEqual(['vid-template'])
})
it('falls back to a single "All" category when entries lack section metadata', () => {
const result = mapHubWorkflowIndexToCategories([
makeEntry({ name: 'template-a', title: 'Template A' }),
makeEntry({ name: 'template-b', title: 'Template B' })
])
expect(result).toHaveLength(1)
expect(result[0].moduleName).toBe('default')
expect(result[0].title).toBe('All Templates')
expect(result[0].templates.map((template) => template.name)).toEqual([
expect(result[0].title).toBe('All')
expect(result[0].templates.map((t) => t.name)).toEqual([
'template-a',
'template-b'
])
})
it('propagates isEssential from entries to categories', () => {
const result = mapHubWorkflowIndexToCategories([
makeEntry({
name: 'essential-1',
section: 'Getting Started',
sectionGroup: 'GENERATION TYPE',
isEssential: true
}),
makeEntry({
name: 'non-essential',
section: 'Getting Started',
sectionGroup: 'GENERATION TYPE',
isEssential: false
})
])
const category = result.find((c) => c.title === 'Getting Started')!
expect(category.isEssential).toBe(true)
})
})

View File

@@ -123,15 +123,46 @@ export function mapHubWorkflowIndexEntryToTemplate(
}
}
// TODO(hub-api): Pending BE spec confirmation — field names (section/sectionGroup) may change.
export function mapHubWorkflowIndexToCategories(
entries: HubWorkflowIndexEntry[],
title: string = 'All'
entries: HubWorkflowIndexEntry[]
): WorkflowTemplates[] {
return [
{
moduleName: 'default',
title,
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
const hasSections = entries.some((e) => e.section)
if (!hasSections) {
return [
{
moduleName: 'default',
title: 'All',
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
}
]
}
const sectionMap = new Map<
string,
{ entries: HubWorkflowIndexEntry[]; sectionGroup?: string }
>()
for (const entry of entries) {
const key = entry.section ?? 'All'
if (!sectionMap.has(key)) {
sectionMap.set(key, { entries: [], sectionGroup: entry.sectionGroup })
}
]
sectionMap.get(key)!.entries.push(entry)
}
return Array.from(
sectionMap,
([section, { entries: sectionEntries, sectionGroup }]) => {
const templates = sectionEntries.map(mapHubWorkflowIndexEntryToTemplate)
return {
moduleName: 'default',
title: section,
category: sectionGroup,
isEssential: sectionEntries.some((e) => e.isEssential),
templates
}
}
)
}

View File

@@ -51,7 +51,29 @@ describe('workflowTemplatesStore', () => {
usage: 10,
searchRank: 5,
isEssential: true,
section: 'Use Cases',
sectionGroup: 'GENERATION TYPE',
thumbnailUrl: 'https://cdn.example.com/thumb.webp'
},
{
name: 'image-gen',
title: 'Image Generation',
status: 'approved',
description: 'Image generation workflow',
shareId: 'share-456',
section: 'Image',
sectionGroup: 'GENERATION TYPE',
thumbnailUrl: 'https://cdn.example.com/img.webp'
},
{
name: 'video-gen',
title: 'Video Generation',
status: 'approved',
description: 'Video generation workflow',
shareId: 'share-789',
section: 'Video',
sectionGroup: 'GENERATION TYPE',
thumbnailUrl: 'https://cdn.example.com/vid.webp'
}
])
@@ -77,24 +99,49 @@ describe('workflowTemplatesStore', () => {
expect(store.knownTemplateNames.has('starter-template')).toBe(true)
})
it('creates a generic getting started nav item for essential cloud templates', async () => {
it('builds nav groups from section metadata on cloud templates', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
const navItem = store.navGroupedTemplates.find(
(item) => 'id' in item && item.id === 'basics-getting-started'
// Essential templates appear under Basics
const basicsNav = store.navGroupedTemplates.find(
(item) => 'id' in item && item.id === 'basics-use-cases'
)
expect(navItem).toEqual({
id: 'basics-getting-started',
label: 'Getting Started',
icon: expect.any(String)
})
expect(basicsNav).toBeDefined()
expect(
store
.filterTemplatesByCategory('basics-getting-started')
.map((template) => template.name)
store.filterTemplatesByCategory('basics-use-cases').map((t) => t.name)
).toEqual(['starter-template'])
// Non-essential templates grouped under GENERATION TYPE
const genTypeGroup = store.navGroupedTemplates.find(
(item) => 'title' in item && item.title === 'Generation Type'
)
expect(genTypeGroup).toBeDefined()
expect('items' in genTypeGroup!).toBe(true)
const groupItems = (genTypeGroup as { items: Array<{ id: string }> }).items
expect(groupItems.map((i) => i.id)).toEqual(
expect.arrayContaining(['generation-type-image', 'generation-type-video'])
)
})
it('filters templates by section-based category id', async () => {
const store = useWorkflowTemplatesStore()
await store.loadWorkflowTemplates()
// Access navGroupedTemplates to populate categoryFilters
expect(store.navGroupedTemplates.length).toBeGreaterThan(0)
const imageTemplates = store.filterTemplatesByCategory(
'generation-type-image'
)
expect(imageTemplates.map((t) => t.name)).toEqual(['image-gen'])
const videoTemplates = store.filterTemplatesByCategory(
'generation-type-video'
)
expect(videoTemplates.map((t) => t.name)).toEqual(['video-gen'])
})
})

View File

@@ -503,10 +503,7 @@ export const useWorkflowTemplatesStore = defineStore(
fetchLogoIndex()
])
coreTemplates.value = mapHubWorkflowIndexToCategories(
hubIndexResult,
st('templateWorkflows.category.All', 'All')
)
coreTemplates.value = mapHubWorkflowIndexToCategories(hubIndexResult)
englishTemplates.value = []
logoIndex.value = logoIndexResult

View File

@@ -8,7 +8,11 @@ export const zHubWorkflowIndexEntry = zHubWorkflowTemplateEntry.extend({
searchRank: z.number().optional(),
isEssential: z.boolean().optional(),
useCase: z.string().optional(),
license: z.string().optional()
license: z.string().optional(),
// TODO(hub-api): Pending BE spec confirmation — field names may change.
// These enable category nav grouping (e.g. section="Image", sectionGroup="GENERATION TYPE").
section: z.string().optional(),
sectionGroup: z.string().optional()
})
export const zHubWorkflowIndexResponse = z.array(zHubWorkflowIndexEntry)