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
34 changes: 28 additions & 6 deletions hugo-apps/src/channels-directory/ChannelsDirectory.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { filterChannels, type Channel } from './filter';
import { filterChannels, ownerBadge, type Channel } from './filter';

const props = defineProps<{ channels: Channel[] }>();
const query = ref('');
const category = ref('');
const platform = ref('');
const focusArea = ref('');
const status = ref('');
const ownerScope = ref<'all' | 'sap' | 'community'>('all');

const categories = computed(() => [...new Set(props.channels.map((c) => c.category).filter(Boolean))].sort());
const platforms = computed(() => [...new Set(props.channels.map((c) => c.platform).filter(Boolean))].sort());
const uniqSorted = (vals: (string | undefined)[]) =>
[...new Set(vals.filter(Boolean) as string[])].sort();
const categories = computed(() => uniqSorted(props.channels.map((c) => c.category)));
const platforms = computed(() => uniqSorted(props.channels.map((c) => c.platform)));
const statuses = computed(() => uniqSorted(props.channels.map((c) => c.status)));
const focusAreas = computed(() => uniqSorted(props.channels.flatMap((c) => c.focusAreas || [])));
const results = computed(() =>
filterChannels(props.channels, { query: query.value, category: category.value, platform: platform.value, ownerScope: ownerScope.value }));
filterChannels(props.channels, {
query: query.value, category: category.value, platform: platform.value,
focusArea: focusArea.value, status: status.value, ownerScope: ownerScope.value,
}));
const badgeClass = (c: Channel) =>
c.isSapOwned || ownerBadge(c).startsWith('SAP') ? 'badge--sap' : 'badge--community';
</script>

<template>
Expand All @@ -24,16 +35,27 @@ const results = computed(() =>
<select v-model="category" aria-label="Category">
<option value="">All categories</option><option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="focusArea" aria-label="Focus area">
<option value="">All focus areas</option><option v-for="f in focusAreas" :key="f" :value="f">{{ f }}</option>
</select>
<select v-model="platform" aria-label="Platform">
<option value="">All platforms</option><option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
</select>
<select v-model="status" aria-label="Status">
<option value="">All statuses</option><option v-for="s in statuses" :key="s" :value="s">{{ s }}</option>
</select>
<span class="channels-directory__count">{{ results.length }} channels</span>
</div>
<ul class="channels-directory__list">
<li v-for="c in results" :key="c.url || c.name" class="channel-card">
<a :href="c.url" target="_blank" rel="noopener">{{ c.name }}</a>
<span v-if="!c.isSapOwned" class="badge badge--community">Community</span>
<p>{{ c.purpose }}</p>
<span class="badge" :class="badgeClass(c)">{{ ownerBadge(c) }}</span>
<p>{{ c.editorialNote || c.purpose }}</p>
<ul v-if="c.relatedUrls && c.relatedUrls.length" class="channel-card__related">
<li v-for="(u, i) in c.relatedUrls" :key="u">
<a :href="u" target="_blank" rel="noopener">Related {{ i + 1 }}</a>
</li>
</ul>
</li>
</ul>
</div>
Expand Down
37 changes: 33 additions & 4 deletions hugo-apps/src/channels-directory/filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { filterChannels } from './filter';
import { filterChannels, ownerBadge } from './filter';

const data = [
{ name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'] },
{ name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'] },
{ name: 'SAP YouTube', category: 'Video', platform: 'YouTube', isSapOwned: true, purpose: 'tutorials', tags: ['video'] },
{ name: 'BTP Docs', category: 'Portal', platform: 'Web', isSapOwned: true, purpose: 'docs', tags: ['btp'], focusAreas: ['btp', 'integration'], status: 'Active', ownerType: 'SAP_Official' },
{ name: 'Reddit SAP', category: 'Community', platform: 'Web', isSapOwned: false, purpose: 'forum', tags: ['community'], focusAreas: ['abap'], status: 'Active', ownerType: 'Community_Organization' },
{ name: 'SAP YouTube', category: 'Video', platform: 'YouTube', isSapOwned: true, purpose: 'tutorials', tags: ['video'], focusAreas: ['ai'], status: 'Archived', ownerType: 'SAP_Developer_Advocate' },
];

describe('filterChannels', () => {
Expand All @@ -22,12 +22,41 @@ describe('filterChannels', () => {
expect(filterChannels(data, { platform: 'Web' })).toHaveLength(2);
expect(filterChannels(data, { platform: 'YouTube' })).toHaveLength(1);
});
it('filters by focus area (membership, not equality)', () => {
expect(filterChannels(data, { focusArea: 'integration' }).map((c) => c.name)).toEqual(['BTP Docs']);
expect(filterChannels(data, { focusArea: 'ai' }).map((c) => c.name)).toEqual(['SAP YouTube']);
expect(filterChannels(data, { focusArea: 'nonexistent' })).toHaveLength(0);
});
it('filters by status', () => {
expect(filterChannels(data, { status: 'Active' }).map((c) => c.name)).toEqual(['BTP Docs', 'Reddit SAP']);
expect(filterChannels(data, { status: 'Archived' }).map((c) => c.name)).toEqual(['SAP YouTube']);
});
it('applies multiple facets together (only rows matching ALL survive)', () => {
// community + query: only Reddit SAP matches both
expect(filterChannels(data, { query: 'forum', ownerScope: 'community' }).map((c) => c.name)).toEqual(['Reddit SAP']);
// sap + category Portal: only BTP Docs matches both
expect(filterChannels(data, { category: 'Portal', ownerScope: 'sap' }).map((c) => c.name)).toEqual(['BTP Docs']);
// sap + Web: BTP Docs only (SAP YouTube is sap but not Web)
expect(filterChannels(data, { ownerScope: 'sap', platform: 'Web' }).map((c) => c.name)).toEqual(['BTP Docs']);
// focusArea + status: BTP Docs is btp+Active; Reddit is abap; no overlap
expect(filterChannels(data, { focusArea: 'btp', status: 'Active' }).map((c) => c.name)).toEqual(['BTP Docs']);
});
});

describe('ownerBadge', () => {
it('derives distinct labels from ownerType (spec §10)', () => {
expect(ownerBadge({ name: 'x', ownerType: 'SAP_Official' })).toBe('SAP');
expect(ownerBadge({ name: 'x', ownerType: 'SAP_Executive' })).toBe('SAP');
expect(ownerBadge({ name: 'x', ownerType: 'SAP_Developer_Advocate' })).toBe('SAP Advocate');
expect(ownerBadge({ name: 'x', ownerType: 'Community_Member' })).toBe('Community');
expect(ownerBadge({ name: 'x', ownerType: 'Community_Organization' })).toBe('Community');
expect(ownerBadge({ name: 'x', ownerType: 'User_Group' })).toBe('User Group');
expect(ownerBadge({ name: 'x', ownerType: 'Third_party_Training' })).toBe('Third-party');
expect(ownerBadge({ name: 'x', ownerType: 'Third_party_Media' })).toBe('Third-party');
});
it('falls back to isSapOwned when ownerType is absent', () => {
expect(ownerBadge({ name: 'x', isSapOwned: true })).toBe('SAP');
expect(ownerBadge({ name: 'x', isSapOwned: false })).toBe('Community');
expect(ownerBadge({ name: 'x' })).toBe('Community');
});
});
24 changes: 23 additions & 1 deletion hugo-apps/src/channels-directory/filter.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
export interface Channel {
name: string; url?: string; purpose?: string; category?: string;
platform?: string; isSapOwned?: boolean; tags?: string[]; ownerType?: string;
subcategory?: string; platform?: string; isSapOwned?: boolean;
tags?: string[]; focusAreas?: string[]; relatedUrls?: string[];
ownerType?: string; ownerName?: string; status?: string; editorialNote?: string;
}
export interface FilterState {
query?: string; category?: string; platform?: string;
focusArea?: string; status?: string;
ownerScope?: 'all' | 'sap' | 'community';
}
export function filterChannels(channels: Channel[], state: FilterState): Channel[] {
const q = (state.query || '').trim().toLowerCase();
return channels.filter((c) => {
if (state.category && c.category !== state.category) return false;
if (state.platform && c.platform !== state.platform) return false;
if (state.status && c.status !== state.status) return false;
if (state.focusArea && !(c.focusAreas || []).includes(state.focusArea)) return false;
if (state.ownerScope === 'sap' && !c.isSapOwned) return false;
if (state.ownerScope === 'community' && c.isSapOwned) return false;
if (q) {
Expand All @@ -20,3 +25,20 @@ export function filterChannels(channels: Channel[], state: FilterState): Channel
return true;
});
}

// Spec §10 labeling — owner_type-derived badge. Falls back to the coarse
// SAP/Community split when ownerType is absent (older ingest rows).
const OWNER_BADGE: Record<string, string> = {
SAP_Official: 'SAP',
SAP_Developer_Advocate: 'SAP Advocate',
SAP_Executive: 'SAP',
Community_Member: 'Community',
Community_Organization: 'Community',
User_Group: 'User Group',
Third_party_Training: 'Third-party',
Third_party_Media: 'Third-party',
Third_party_Platform: 'Third-party',
};
export function ownerBadge(c: Channel): string {
return OWNER_BADGE[c.ownerType || ''] || (c.isSapOwned ? 'SAP' : 'Community');
}
8 changes: 7 additions & 1 deletion srv/lib/channels/promote-to-shelves.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ const CATEGORY_TO_SHELF = {
'YouTube': 'KEEP_CURRENT', 'Podcast': 'KEEP_CURRENT', 'Blog': 'KEEP_CURRENT', 'News': 'KEEP_CURRENT',
'Learning': 'START_HERE', 'Community': 'REFERENCE',
};
// Case-insensitive lookup so ingest variance ("github repository", "Docs"
// vs "docs") still maps deterministically instead of silently defaulting.
const CATEGORY_TO_SHELF_LC = Object.fromEntries(
Object.entries(CATEGORY_TO_SHELF).map(([k, v]) => [k.toLowerCase(), v]),
);
const FOCUS_TO_VERB = [
[['integration'], 'INTEGRATE'], [['ops', 'admin', 'operations'], 'OPERATE'],
[['ai', 'genai'], 'AI'], [['rap', 'data-model', 'cds'], 'MODEL'],
[['abap', 'cap', 'sdk', 'build'], 'BUILD'], [['onboarding', 'tutorial', 'learn'], 'LEARN'],
[['community', 'network', 'networking', 'events', 'connect'], 'CONNECT'],
];

function pickVerb(focusAreas = []) {
Expand All @@ -21,7 +27,7 @@ function pickVerb(focusAreas = []) {
}

function mapChannelToShelf(channel) {
let shelf = CATEGORY_TO_SHELF[channel.category] || 'REFERENCE';
let shelf = CATEGORY_TO_SHELF_LC[String(channel.category || '').toLowerCase()] || 'REFERENCE';
// community / third-party may never land in START_HERE
if (shelf === 'START_HERE' && channel.isSapOwned !== true) shelf = 'REFERENCE';
return { verb: pickVerb(channel.focusAreas), shelf };
Expand Down
17 changes: 15 additions & 2 deletions srv/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,27 @@ cds.on('bootstrap', (app) => {
.orderBy('category', 'name'),
);
const parseArr = (v) => (Array.isArray(v) ? v : (typeof v === 'string' && v ? JSON.parse(v) : []));
// Explicit public projection — never spread the full row into an
// anon feed (drops managed audit + internal curation columns:
// sourceId, notes, aliases, contentHash, ingestBatch, lastChecked,
// isFeatured, linkStatusOverride, createdBy/modifiedBy, …).
const channels = rows
.map((r) => ({
...r,
name: r.name,
url: r.url,
purpose: r.purpose,
category: r.category,
subcategory: r.subcategory,
platform: r.platform,
isSapOwned: r.isSapOwned,
ownerType: r.ownerType,
ownerName: r.ownerName,
status: r.status,
editorialNote: r.editorialNote,
linkStatus: r.linkStatusOverride || r.linkStatus,
focusAreas: parseArr(r.focusAreas),
tags: parseArr(r.tags),
relatedUrls: parseArr(r.relatedUrls),
aliases: parseArr(r.aliases),
}))
.filter((r) => r.linkStatus !== 'BROKEN');
res.set('Cache-Control', 'public, max-age=60');
Expand Down
22 changes: 17 additions & 5 deletions test/build-channels-feed.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,24 @@ describe('GET /build/channels', () => {
it('returns only published, non-broken channels with parsed arrays', async () => {
const { status, data } = await project.get('/build/channels');
expect(status).toBe(200);
const ids = data.channels.map((c) => c.sourceId);
expect(ids).toContain('feed-pub');
expect(ids).not.toContain('feed-unpub');
expect(ids).not.toContain('feed-broken');
const pub = data.channels.find((c) => c.sourceId === 'feed-pub');
const urls = data.channels.map((c) => c.url);
expect(urls).toContain('https://pub');
expect(urls).not.toContain('https://unpub');
expect(urls).not.toContain('https://broken');
const pub = data.channels.find((c) => c.url === 'https://pub');
expect(pub.focusAreas).toEqual(['btp']);
expect(typeof data.buildAt).toBe('string');
});

it('projects a public whitelist — no audit / internal curation columns', async () => {
const { data } = await project.get('/build/channels');
const pub = data.channels.find((c) => c.url === 'https://pub');
for (const internal of ['sourceId', 'contentHash', 'ingestBatch', 'linkStatusOverride', 'isFeatured', 'notes', 'aliases', 'createdBy', 'modifiedBy', 'createdAt', 'modifiedAt']) {
expect(pub, `feed leaked internal column "${internal}"`).not.toHaveProperty(internal);
}
// consumed public fields are present
for (const pubfield of ['name', 'url', 'category', 'status', 'ownerType', 'focusAreas']) {
expect(pub).toHaveProperty(pubfield);
}
});
});
10 changes: 9 additions & 1 deletion test/channels-promote.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ describe('mapChannelToShelf', () => {
});
it('never puts a community channel in START_HERE', () => {
const m = mapChannelToShelf({ isSapOwned: false, category: 'Learning', focusAreas: ['onboarding'] });
expect(m?.shelf).not.toBe('START_HERE');
expect(m.shelf).not.toBe('START_HERE');
});
it('maps a GitHub repo to TOOLS', () => {
expect(mapChannelToShelf({ isSapOwned: true, category: 'GitHub Repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS');
});
it('matches category case-insensitively', () => {
expect(mapChannelToShelf({ isSapOwned: true, category: 'github repository', focusAreas: ['cap'] }).shelf).toBe('TOOLS');
expect(mapChannelToShelf({ isSapOwned: true, category: 'DOCS', focusAreas: ['cap'] }).shelf).toBe('REFERENCE');
});
it('reaches the CONNECT verb for community/networking focus areas', () => {
expect(mapChannelToShelf({ isSapOwned: true, category: 'Community', focusAreas: ['community'] }).verb).toBe('CONNECT');
expect(mapChannelToShelf({ isSapOwned: true, category: 'Portal', focusAreas: ['networking'] }).verb).toBe('CONNECT');
});
});

describe('promoteFeatured', () => {
Expand Down
Loading