Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion approuter/xs-app.json
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@
"authenticationType": "none"
},
{
"source": "^/build/(breadcrumb-context|catalog|co-completions|concepts|homepage-shelves|kg-stats|mission|my-progress|navigator|repo-catalog|slug-mapping|tag-labels|topics-gallery|topics-tree|topics)(/.*)?(\\?.*)?$",
"source": "^/build/(channels-stats|channels|channel-collections|channel-atlas|breadcrumb-context|catalog|co-completions|concepts|homepage-shelves|kg-stats|mission|my-progress|navigator|repo-catalog|slug-mapping|tag-labels|topics-gallery|topics-tree|topics)(/.*)?(\\?.*)?$",
"target": "/build/$1$2$3",
"destination": "srv-api",
"authenticationType": "none"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import ChannelsDirectory from './ChannelsDirectory.vue';

describe('ChannelsDirectory — hub band', () => {
const wrapper = () => mount(ChannelsDirectory, { props: { channels: [], collections: [] } });

it('renders four hub navigation cards', () => {
const links = wrapper().findAll('.channels-hub-band__cards a');
expect(links).toHaveLength(4);
});

it('card hrefs include atlas, health, and media-diet', () => {
const hrefs = wrapper().findAll('.channels-hub-band__cards a').map((l) => l.attributes('href'));
expect(hrefs).toContain('/channels/atlas/');
expect(hrefs).toContain('/channels/health/');
expect(hrefs).toContain('/channels/media-diet/');
});

it('hub band appears in DOM before the filter controls', () => {
const html = wrapper().html();
const hubPos = html.indexOf('channels-hub-band');
const ctrlPos = html.indexOf('channels-directory__controls');
expect(hubPos).toBeGreaterThan(-1);
expect(hubPos).toBeLessThan(ctrlPos);
});

it('each card has a title and an icon name', () => {
const cards = wrapper().findAll('.channels-hub-band__cards li');
expect(cards).toHaveLength(4);
for (const card of cards) {
expect(card.find('.hub-card__title').text()).toBeTruthy();
const icon = card.find('ui5-icon');
expect(icon.attributes('name')).toBeTruthy();
}
});
});
85 changes: 85 additions & 0 deletions hugo-apps/src/channels-directory/ChannelsDirectory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,43 @@ const cols = computed(() => visibleCollections(props.collections));

<template>
<div class="channels-directory">
<section class="channels-hub-band">
<p class="channels-hub-band__intro">
SAP developers consume content across dozens of surfaces — portals, docs,
YouTube channels, podcasts, GitHub orgs, and community spaces. This directory
maps that ecosystem so you can find the channels that fit your workflow.
</p>
<ul class="channels-hub-band__cards">
<li>
<a href="/channels/atlas/" class="hub-card">
<ui5-icon name="org-chart" class="hub-card__icon"></ui5-icon>
<span class="hub-card__title">Channel Atlas</span>
<span class="hub-card__desc">Visual map of the full ecosystem</span>
</a>
</li>
<li>
<a href="/channels/crosswalk/" class="hub-card">
<ui5-icon name="chain-link" class="hub-card__icon"></ui5-icon>
<span class="hub-card__title">Learn ↔ Follow</span>
<span class="hub-card__desc">Tutorial topics to channels crosswalk</span>
</a>
</li>
<li>
<a href="/channels/health/" class="hub-card">
<ui5-icon name="sys-monitor" class="hub-card__icon"></ui5-icon>
<span class="hub-card__title">Ecosystem Health</span>
<span class="hub-card__desc">Active-vs-inactive and coverage radar</span>
</a>
</li>
<li>
<a href="/channels/media-diet/" class="hub-card">
<ui5-icon name="favorite" class="hub-card__icon"></ui5-icon>
<span class="hub-card__title">Build Your Media Diet</span>
<span class="hub-card__desc">Get a personalized channel bundle</span>
</a>
</li>
</ul>
</section>
<section v-if="cols.length" class="channel-collections">
<article v-for="col in cols" :key="col.slug" class="collection">
<h2>{{ col.title }}</h2>
Expand Down Expand Up @@ -202,6 +239,54 @@ const cols = computed(() => visibleCollections(props.collections));
background: var(--sapSuccessBackground, #e5f6e6);
}

/* --- Hub navigation band --- */
.channels-hub-band {
display: flex;
flex-direction: column;
gap: 1rem;
}
.channels-hub-band__intro {
margin: 0;
color: var(--sapTextColor, #1d2d3e);
font-size: 1rem;
}
.channels-hub-band__cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.75rem;
}
.hub-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
padding: 1rem 1.125rem;
border: 1px solid var(--sapList_BorderColor, #d9d9d9);
border-radius: var(--sapElement_BorderCornerRadius, 0.75rem);
background: var(--sapGroup_ContentBackground, #fff);
text-decoration: none;
transition: box-shadow 0.12s;
}
.hub-card:hover {
box-shadow: var(--sapContent_Shadow1, 0 0 0.5rem rgba(0, 0, 0, 0.12));
}
.hub-card__icon {
font-size: 1.5rem;
color: var(--sapAccentColor6, #0064d9);
}
.hub-card__title {
font-weight: 600;
font-size: 1rem;
color: var(--sapLinkColor, #0070f2);
}
.hub-card__desc {
font-size: 0.875rem;
color: var(--sapNeutralTextColor, #556b82);
}

@media (max-width: 640px) {
.channels-directory__count {
margin-left: 0;
Expand Down
65 changes: 65 additions & 0 deletions hugo-apps/src/channels-health/ChannelsHealth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import ChannelsHealth from './ChannelsHealth.vue';

const SAMPLE_STATS = {
total: 150,
publishedCount: 120,
byStatus: { Active: 110, Archived: 30, Closed: 10 },
byOwnerType: { SAP_Official: 50, Community_Member: 80, SAP_Developer_Advocate: 20 },
byCategory: { Documentation: 45, Community: 60, 'Developer Tools': 45 },
bySubcategory: { 'API Docs': 20, Forum: 30 },
sapVsCommunity: { sap: 70, community: 80 },
activeVsInactive: { active: 110, inactive: 40 },
buildAt: '2026-09-05T10:00:00.000Z',
error: null,
};

describe('ChannelsHealth', () => {
it('renders the total channel count', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
expect(wrapper.text()).toContain('150');
});

it('renders active vs inactive counts', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
expect(wrapper.text()).toContain('110');
expect(wrapper.text()).toContain('40');
});

it('renders SAP vs community counts', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
expect(wrapper.text()).toContain('70');
expect(wrapper.text()).toContain('80');
});

it('renders status breakdown entries', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
expect(wrapper.text()).toContain('Active');
expect(wrapper.text()).toContain('Archived');
});

it('renders category coverage entries', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
expect(wrapper.text()).toContain('Documentation');
expect(wrapper.text()).toContain('Community');
});

it('does NOT render any panel referencing linkStatus, lastChecked, or updateFrequency', () => {
const wrapper = mount(ChannelsHealth, { props: { stats: SAMPLE_STATS } });
const html = wrapper.html();
expect(html).not.toMatch(/linkStatus/i);
expect(html).not.toMatch(/lastChecked/i);
expect(html).not.toMatch(/updateFrequency/i);
expect(html).not.toMatch(/link status/i);
expect(html).not.toMatch(/last checked/i);
expect(html).not.toMatch(/update frequency/i);
});

it('shows empty-state message when stats.total is 0', () => {
const empty = { ...SAMPLE_STATS, total: 0, publishedCount: 0, byStatus: {}, byOwnerType: {}, byCategory: {}, bySubcategory: {}, sapVsCommunity: { sap: 0, community: 0 }, activeVsInactive: { active: 0, inactive: 0 } };
const wrapper = mount(ChannelsHealth, { props: { stats: empty } });
expect(wrapper.text()).toMatch(/no channel data|loading|stats not/i);
});
});
180 changes: 180 additions & 0 deletions hugo-apps/src/channels-health/ChannelsHealth.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed } from 'vue';

interface ChannelsStats {
total?: number;
publishedCount?: number;
byStatus?: Record<string, number>;
byOwnerType?: Record<string, number>;
byCategory?: Record<string, number>;
bySubcategory?: Record<string, number>;
sapVsCommunity?: { sap: number; community: number };
activeVsInactive?: { active: number; inactive: number };
buildAt?: string;
error?: string | null;
}

const props = defineProps<{ stats: ChannelsStats }>();

const isEmpty = computed(() => !props.stats?.total);

const statusEntries = computed(() =>
Object.entries(props.stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]),
);
const ownerTypeEntries = computed(() =>
Object.entries(props.stats?.byOwnerType ?? {}).sort((a, b) => b[1] - a[1]),
);
const categoryEntries = computed(() =>
Object.entries(props.stats?.byCategory ?? {}).sort((a, b) => b[1] - a[1]),
);
</script>

<template>
<div class="channels-health">
<p v-if="isEmpty" class="channels-health__empty">No channel data available yet.</p>
<template v-else>
<!-- Summary row -->
<div class="health-summary">
<div class="health-stat">
<span class="health-stat__value">{{ stats.total ?? 0 }}</span>
<span class="health-stat__label">Total channels</span>
</div>
<div class="health-stat">
<span class="health-stat__value">{{ stats.publishedCount ?? 0 }}</span>
<span class="health-stat__label">Published</span>
</div>
<div class="health-stat">
<span class="health-stat__value">{{ stats.activeVsInactive?.active ?? 0 }}</span>
<span class="health-stat__label">Active</span>
</div>
<div class="health-stat">
<span class="health-stat__value">{{ stats.activeVsInactive?.inactive ?? 0 }}</span>
<span class="health-stat__label">Inactive</span>
</div>
<div class="health-stat">
<span class="health-stat__value">{{ stats.sapVsCommunity?.sap ?? 0 }}</span>
<span class="health-stat__label">SAP-owned</span>
</div>
<div class="health-stat">
<span class="health-stat__value">{{ stats.sapVsCommunity?.community ?? 0 }}</span>
<span class="health-stat__label">Community</span>
</div>
</div>

<!-- Status breakdown -->
<section class="health-panel">
<h2 class="health-panel__title">By Status</h2>
<ul class="health-panel__list">
<li v-for="[status, count] in statusEntries" :key="status" class="health-panel__row">
<span class="health-panel__name">{{ status }}</span>
<span class="health-panel__count">{{ count }}</span>
</li>
</ul>
</section>

<!-- Category coverage -->
<section class="health-panel">
<h2 class="health-panel__title">By Category</h2>
<ul class="health-panel__list">
<li v-for="[category, count] in categoryEntries" :key="category" class="health-panel__row">
<span class="health-panel__name">{{ category }}</span>
<span class="health-panel__count">{{ count }}</span>
</li>
</ul>
</section>

<!-- Owner type breakdown -->
<section class="health-panel">
<h2 class="health-panel__title">By Owner Type</h2>
<ul class="health-panel__list">
<li v-for="[ownerType, count] in ownerTypeEntries" :key="ownerType" class="health-panel__row">
<span class="health-panel__name">{{ ownerType.replace(/_/g, ' ') }}</span>
<span class="health-panel__count">{{ count }}</span>
</li>
</ul>
</section>

<p v-if="stats.buildAt" class="health-footer">
Stats as of {{ new Date(stats.buildAt).toLocaleDateString() }}
</p>
</template>
</div>
</template>

<style scoped>
.channels-health {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.channels-health__empty {
color: var(--sapNeutralTextColor, #556b82);
}
.health-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
}
.health-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.875rem;
border: 1px solid var(--sapList_BorderColor, #d9d9d9);
border-radius: var(--sapElement_BorderCornerRadius, 0.75rem);
background: var(--sapGroup_ContentBackground, #fff);
}
.health-stat__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--sapAccentColor6, #0064d9);
}
.health-stat__label {
font-size: 0.8125rem;
color: var(--sapNeutralTextColor, #556b82);
text-align: center;
}
.health-panel {
padding: 1rem 1.25rem;
border: 1px solid var(--sapList_BorderColor, #d9d9d9);
border-radius: var(--sapElement_BorderCornerRadius, 0.75rem);
background: var(--sapGroup_ContentBackground, #fff);
}
.health-panel__title {
margin: 0 0 0.75rem;
font-size: 1rem;
font-weight: 600;
color: var(--sapGroup_TitleTextColor, #1d2d3e);
}
.health-panel__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.health-panel__row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
border-bottom: 1px solid var(--sapList_BorderColor, #d9d9d9);
font-size: 0.9375rem;
}
.health-panel__row:last-child {
border-bottom: none;
}
.health-panel__name {
color: var(--sapTextColor, #1d2d3e);
}
.health-panel__count {
font-weight: 600;
color: var(--sapAccentColor6, #0064d9);
}
.health-footer {
font-size: 0.8125rem;
color: var(--sapNeutralTextColor, #556b82);
margin: 0;
}
</style>
Loading
Loading