diff --git a/i18n/locales/de.json b/i18n/locales/de.json index fdb18dbb..59bfea6a 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -601,6 +601,16 @@ "description": "Software von OneLiteFeather: Plugins und Werkzeuge, die wir selbst entwickeln und auf unserem Server einsetzen — mit Dokumentation, Quellcode und Downloads.", "empty": "Noch keine Projekte veröffentlicht." }, + "filter": { + "audience_label": "Zielgruppe", + "audience": { + "all": "Alle" + }, + "platform_label": "Plattform", + "count": "{shown} von {total} Projekten", + "reset": "Filter zurücksetzen", + "empty": "Kein Projekt passt zu diesem Filter." + }, "status": { "active": "Aktiv", "maintenance": "In Wartung", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 867d7d00..14e9f05b 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -601,6 +601,16 @@ "description": "Software by OneLiteFeather: plugins and tools we build ourselves and run on our server — with documentation, source code and downloads.", "empty": "No projects published yet." }, + "filter": { + "audience_label": "Audience", + "audience": { + "all": "All" + }, + "platform_label": "Platform", + "count": "{shown} of {total} projects", + "reset": "Reset filters", + "empty": "No project matches this filter." + }, "status": { "active": "Active", "maintenance": "Maintained", diff --git a/layers/projects/components/ProjectFilters.vue b/layers/projects/components/ProjectFilters.vue new file mode 100644 index 00000000..a6e35429 --- /dev/null +++ b/layers/projects/components/ProjectFilters.vue @@ -0,0 +1,92 @@ + + + + + + {{ t('projects.filter.audience_label') }} + + + + + {{ t('projects.filter.platform_label') }} + + + + + + {{ t('projects.filter.count', { shown, total }) }} + + + {{ t('projects.filter.reset') }} + + + + diff --git a/layers/projects/utils/projectFilter.ts b/layers/projects/utils/projectFilter.ts new file mode 100644 index 00000000..810ba569 --- /dev/null +++ b/layers/projects/utils/projectFilter.ts @@ -0,0 +1,85 @@ +import { PROJECT_AUDIENCES, type ProjectAudience, type ProjectSummary } from '../types' + +export type ProjectAudienceFilter = 'all' | ProjectAudience + +/** Platforms combine as "any of": a project matches when it runs on one of them. */ +export interface ProjectFilter { + audience: ProjectAudienceFilter + platforms: string[] +} + +export interface PlatformFacet { + platform: string + count: number +} + +export const EMPTY_PROJECT_FILTER: ProjectFilter = { audience: 'all', platforms: [] } + +// Explicit locale: server and browser must order the chips identically or +// hydration disagrees. +const comparePlatforms = (a: string, b: string): number => a.localeCompare(b, 'en') + +export function isProjectFilterActive(filter: ProjectFilter): boolean { + return filter.audience !== 'all' || filter.platforms.length > 0 +} + +/** Each platform any project names, with how many projects name it, alphabetical. */ +export function platformFacets(projects: readonly ProjectSummary[]): PlatformFacet[] { + const counts = new Map() + for (const project of projects) { + const names = new Set((project.platforms ?? []).map((name) => name.trim()).filter(Boolean)) + for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1) + } + return [...counts] + .map(([platform, count]) => ({ platform, count })) + .sort((a, b) => comparePlatforms(a.platform, b.platform)) +} + +export function filterProjects( + projects: readonly ProjectSummary[], + filter: ProjectFilter, +): ProjectSummary[] { + return projects.filter((project) => { + if (filter.audience !== 'all' && project.audience !== filter.audience) return false + if (filter.platforms.length === 0) return true + return (project.platforms ?? []).some((platform) => filter.platforms.includes(platform)) + }) +} + +export function togglePlatform(filter: ProjectFilter, platform: string): ProjectFilter { + const platforms = filter.platforms.includes(platform) + ? filter.platforms.filter((selected) => selected !== platform) + : [...filter.platforms, platform] + return { ...filter, platforms } +} + +/** + * Reads the filter from a query string. Unknown audiences and platforms that + * no project names are dropped, so a stale or hand-edited link shows the + * unfiltered list rather than an empty one. + */ +export function parseProjectFilter( + query: URLSearchParams, + knownPlatforms: readonly string[], +): ProjectFilter { + const audience = query.get('audience') + const known = new Set(knownPlatforms) + const platforms = [...new Set((query.get('platform') ?? '').split(',').map((name) => name.trim()))] + .filter((name) => known.has(name)) + .sort(comparePlatforms) + return { + audience: (PROJECT_AUDIENCES as readonly string[]).includes(audience ?? '') + ? (audience as ProjectAudience) + : 'all', + platforms, + } +} + +/** The query parameters for a filter; defaults are left out so the unfiltered page has no query. */ +export function serializeProjectFilter(filter: ProjectFilter): Record { + const query: Record = {} + if (filter.audience !== 'all') query.audience = filter.audience + const platforms = [...new Set(filter.platforms)].sort(comparePlatforms) + if (platforms.length) query.platform = platforms.join(',') + return query +} diff --git a/pages/projects/index.vue b/pages/projects/index.vue index 8390a8cb..db74b436 100644 --- a/pages/projects/index.vue +++ b/pages/projects/index.vue @@ -3,6 +3,8 @@ import { definePageMeta } from '#imports' const { t, locale } = useI18n() const site = useSiteConfig() +const route = useRoute() +const router = useRouter() definePageMeta({ layout: 'default' @@ -10,10 +12,40 @@ definePageMeta({ const { projects } = await useProjectsOverview() +const facets = computed(() => platformFacets(projects.value)) + +// Read after mount only: the cached server render must not depend on the request +// (request-independent-render.spec.ts), so the server renders every project. +const hydrated = ref(false) +onMounted(() => { + hydrated.value = true +}) + +const filter = computed(() => { + if (!hydrated.value) return EMPTY_PROJECT_FILTER + const query = new URL(route.fullPath, 'http://localhost').searchParams + return parseProjectFilter(query, facets.value.map((facet) => facet.platform)) +}) + +const setFilter = (next: ProjectFilter) => { + router.replace({ query: serializeProjectFilter(next) }) +} + const groups = computed(() => groupProjectsByAudience(projects.value)) -// Same order the page shows, so the ItemList positions match what a reader sees. +const visibleGroups = computed(() => { + return groupProjectsByAudience(filterProjects(projects.value, filter.value)) +}) +const visibleCount = computed(() => { + return visibleGroups.value.reduce((sum, group) => sum + group.projects.length, 0) +}) +// Every project, whatever the filter, in the order the unfiltered page shows, +// so the ItemList positions match what a reader sees. const orderedProjects = computed(() => groups.value.flatMap((group) => group.projects)) +const emptyClass + = 'rounded-large border border-dashed border-outline-variant p-8 text-center ' + + 'text-body-medium text-on-surface-variant' + usePageSeo({ title: t('projects.overview.title'), description: t('projects.overview.description'), @@ -61,7 +93,19 @@ useSchemaOrg(computed(() => { - + + + + {{ t('projects.filter.empty') }} + + + { + it('builds the ItemList from every project, not from the filtered list', () => { + expect(overview).toMatch(/groups = computed\(\(\) =>\s*groupProjectsByAudience\(projects/) + expect(overview).toMatch(/orderedProjects = computed\(\(\) => groups\.value\.flatMap/) + }) + + it('writes the filter with router.replace, so every change stays out of the history', () => { + expect(overview).toContain('router.replace(') + expect(overview).not.toContain('router.push(') + }) + + it('reads the URL only after mount, so the cached server render stays the same for every query', () => { + expect(overview).toMatch(/onMounted\(\(\) => \{\s*hydrated\.value = true/) + }) +}) + describe('project detail page', () => { it('lists links through ResourceList and maintainers through PersonLink', () => { expect(detail).toContain(' = {}): ProjectSummary => ({ + slug, + title: slug, + summary: slug, + status: 'active', + audience: 'external', + platforms: [], + ...extra, +}) as ProjectSummary + +const fixtures = [ + project('cygnus', { platforms: ['Minestom'] }), + project('butterfly', { platforms: ['Paper', 'Minestom'] }), + project('apus', { platforms: ['Kubernetes', 'Paper'], audience: 'internal' }), + project('titan', { platforms: ['Minestom', 'CloudNet'], audience: 'internal' }), + project('renovate-presets', { platforms: ['Renovate'] }), +] + +const slugs = (list: ProjectSummary[]) => list.map((p) => p.slug) + +describe('filterProjects', () => { + it('returns every project for the empty filter', () => { + expect(slugs(filterProjects(fixtures, EMPTY_PROJECT_FILTER))).toEqual(slugs(fixtures)) + }) + + it('keeps only the chosen audience', () => { + const internal = filterProjects(fixtures, { audience: 'internal', platforms: [] }) + expect(slugs(internal)).toEqual(['apus', 'titan']) + }) + + it('matches a project that runs on any one of the selected platforms', () => { + const result = filterProjects(fixtures, { audience: 'all', platforms: ['Paper', 'CloudNet'] }) + expect(slugs(result)).toEqual(['butterfly', +'apus', +'titan']) + }) + + it('combines the audience and the platforms with and', () => { + const result = filterProjects(fixtures, { audience: 'internal', platforms: ['Minestom'] }) + expect(slugs(result)).toEqual(['titan']) + }) + + it('drops a project without platforms as soon as a platform is selected', () => { + const result = filterProjects([project('bare')], { audience: 'all', platforms: ['Paper'] }) + expect(result).toEqual([]) + }) + + it('returns an empty list when nothing matches', () => { + expect(filterProjects(fixtures, { audience: 'internal', platforms: ['Renovate'] })).toEqual([]) + }) +}) + +describe('platformFacets', () => { + it('counts each platform once for every project that names it', () => { + const facets = platformFacets(fixtures) + expect(facets.find((f) => f.platform === 'Minestom')?.count).toBe(3) + expect(facets.find((f) => f.platform === 'Paper')?.count).toBe(2) + expect(facets.find((f) => f.platform === 'Renovate')?.count).toBe(1) + }) + + it('counts a platform listed twice on one project once', () => { + const facets = platformFacets([project('dup', { platforms: ['Paper', 'Paper'] })]) + expect(facets).toEqual([{ platform: 'Paper', count: 1 }]) + }) + + it('lists the platforms alphabetically', () => { + expect(platformFacets(fixtures).map((f) => f.platform)).toEqual([ + 'CloudNet', + 'Kubernetes', + 'Minestom', + 'Paper', + 'Renovate', + ]) + }) + + it('ignores blank platform names and projects without platforms', () => { + const facets = platformFacets([project('blank', { platforms: [' ', 'Docker'] }), project('none')]) + expect(facets).toEqual([{ platform: 'Docker', count: 1 }]) + }) +}) + +describe('togglePlatform', () => { + it('adds a platform that is not selected yet', () => { + expect(togglePlatform({ audience: 'internal', platforms: ['Paper'] }, 'Minestom')) + .toEqual({ audience: 'internal', platforms: ['Paper', 'Minestom'] }) + }) + + it('removes a platform that is already selected and keeps the audience', () => { + expect(togglePlatform({ audience: 'internal', platforms: ['Paper', 'Minestom'] }, 'Paper')) + .toEqual({ audience: 'internal', platforms: ['Minestom'] }) + }) +}) + +describe('parseProjectFilter', () => { + const known = ['CloudNet', +'Minestom', +'Paper'] + + it('reads the audience and the comma-separated platforms', () => { + const filter = parseProjectFilter(new URLSearchParams('audience=internal&platform=Minestom,Paper'), known) + expect(filter).toEqual({ audience: 'internal', platforms: ['Minestom', 'Paper'] }) + }) + + it('ignores an audience that is not a known value', () => { + expect(parseProjectFilter(new URLSearchParams('audience=secret'), known).audience).toBe('all') + expect(parseProjectFilter(new URLSearchParams('audience=all'), known).audience).toBe('all') + }) + + it('drops platforms that no project names', () => { + const filter = parseProjectFilter(new URLSearchParams('platform=Minestom,Velocity'), known) + expect(filter.platforms).toEqual(['Minestom']) + }) + + it('removes duplicate platforms', () => { + const filter = parseProjectFilter(new URLSearchParams('platform=Paper,Paper'), known) + expect(filter.platforms).toEqual(['Paper']) + }) + + it('returns the empty filter for a query without filter parameters', () => { + expect(parseProjectFilter(new URLSearchParams('utm_source=x'), known)).toEqual(EMPTY_PROJECT_FILTER) + }) +}) + +describe('serializeProjectFilter', () => { + it('writes nothing for the empty filter, so the unfiltered page keeps a bare URL', () => { + expect(serializeProjectFilter(EMPTY_PROJECT_FILTER)).toEqual({}) + }) + + it('writes the platforms sorted and comma-separated', () => { + expect(serializeProjectFilter({ audience: 'all', platforms: ['Paper', 'Minestom'] })) + .toEqual({ platform: 'Minestom,Paper' }) + }) + + it('round-trips through the query string', () => { + const filter = { audience: 'external' as const, platforms: ['CloudNet', 'Paper'] } + const query = new URLSearchParams(serializeProjectFilter(filter)) + expect(parseProjectFilter(query, ['CloudNet', 'Paper'])).toEqual(filter) + }) +}) + +describe('isProjectFilterActive', () => { + it('is false for the empty filter and true once either part is set', () => { + expect(isProjectFilterActive(EMPTY_PROJECT_FILTER)).toBe(false) + expect(isProjectFilterActive({ audience: 'internal', platforms: [] })).toBe(true) + expect(isProjectFilterActive({ audience: 'all', platforms: ['Paper'] })).toBe(true) + }) +}) diff --git a/tests/projects/project-filters.spec.ts b/tests/projects/project-filters.spec.ts new file mode 100644 index 00000000..81ad6808 --- /dev/null +++ b/tests/projects/project-filters.spec.ts @@ -0,0 +1,84 @@ +// @vitest-environment nuxt +import { mountSuspended } from '@nuxt/test-utils/runtime' +import type { VueWrapper } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ProjectFilters from '../../layers/projects/components/ProjectFilters.vue' +import { EMPTY_PROJECT_FILTER, type PlatformFacet, type ProjectFilter } from '../../layers/projects/utils/projectFilter' + +const facets: PlatformFacet[] = [ + { platform: 'Minestom', count: 3 }, { platform: 'Paper', count: 2 }, +] + +const mountFilters = (filter: ProjectFilter = EMPTY_PROJECT_FILTER, shown = 5, total = 5) => { + return mountSuspended(ProjectFilters, { + props: { filter, facets, shown, total }, + route: '/de/projects', + }) +} + +const chip = (wrapper: VueWrapper, label: string) => { + const found = wrapper.findAll('button').find((button) => button.text() === label) + if (!found) throw new Error(`no chip labelled "${label}"`) + return found +} + +const emittedFilters = (wrapper: VueWrapper): ProjectFilter[] => { + return (wrapper.emitted('update:filter') ?? []).map(([filter]) => filter as ProjectFilter) +} + +describe('ProjectFilters audience chips', () => { + it('presses only the chip of the chosen audience', async () => { + const wrapper = await mountFilters({ audience: 'internal', platforms: [] }) + expect(chip(wrapper, 'Intern').attributes('aria-pressed')).toBe('true') + expect(chip(wrapper, 'Alle').attributes('aria-pressed')).toBe('false') + expect(chip(wrapper, 'Öffentlich nutzbar').attributes('aria-pressed')).toBe('false') + }) + + it('emits the clicked audience and keeps the platforms', async () => { + const wrapper = await mountFilters({ audience: 'all', platforms: ['Paper'] }) + await chip(wrapper, 'Öffentlich nutzbar').trigger('click') + expect(emittedFilters(wrapper)).toEqual([{ audience: 'external', platforms: ['Paper'] }]) + }) +}) + +describe('ProjectFilters platform chips', () => { + it('labels each platform with the number of projects that run on it', async () => { + const wrapper = await mountFilters() + expect(chip(wrapper, 'Minestom (3)').attributes('aria-pressed')).toBe('false') + expect(chip(wrapper, 'Paper (2)').attributes('aria-pressed')).toBe('false') + }) + + it('adds a platform to the selection and keeps the audience', async () => { + const wrapper = await mountFilters({ audience: 'internal', platforms: ['Paper'] }) + await chip(wrapper, 'Minestom (3)').trigger('click') + expect(emittedFilters(wrapper)).toEqual([{ audience: 'internal', platforms: ['Paper', 'Minestom'] }]) + }) + + it('removes a platform that is already selected', async () => { + const wrapper = await mountFilters({ audience: 'all', platforms: ['Paper'] }) + expect(chip(wrapper, 'Paper (2)').attributes('aria-pressed')).toBe('true') + await chip(wrapper, 'Paper (2)').trigger('click') + expect(emittedFilters(wrapper)).toEqual([{ audience: 'all', platforms: [] }]) + }) +}) + +describe('ProjectFilters result count and reset', () => { + it('announces the visible and total count politely', async () => { + const wrapper = await mountFilters(EMPTY_PROJECT_FILTER, 2, 5) + const live = wrapper.find('[aria-live="polite"]') + expect(live.exists()).toBe(true) + expect(live.text()).toBe('2 von 5 Projekten') + }) + + it('offers no reset action while nothing is selected', async () => { + const wrapper = await mountFilters() + const reset = wrapper.findAll('button').find((button) => button.text() === 'Filter zurücksetzen') + expect(reset).toBeUndefined() + }) + + it('clears every selection with the reset action', async () => { + const wrapper = await mountFilters({ audience: 'internal', platforms: ['Minestom'] }, 1, 5) + await chip(wrapper, 'Filter zurücksetzen').trigger('click') + expect(emittedFilters(wrapper)).toEqual([EMPTY_PROJECT_FILTER]) + }) +})
+ {{ t('projects.filter.count', { shown, total }) }} +
+ {{ t('projects.filter.empty') }} +