diff --git a/src/components/ArchitectureFilters/index.js b/src/components/ArchitectureFilters/index.js index 353c9172..a064e8ce 100644 --- a/src/components/ArchitectureFilters/index.js +++ b/src/components/ArchitectureFilters/index.js @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { useFilterToolbar } from '../shared/useFilterToolbar'; import FilterToolbar from '../shared/FilterToolbar'; +import { compareLabels } from '../shared/compareLabels'; const DEFAULT_FILTERS = { query: '', @@ -59,9 +60,9 @@ export function useArchitectureFilterOptions(architectures) { }); } return { - industries: Array.from(industries).sort(), - projects: Array.from(projects).sort(), - organizations: Array.from(organizations).sort(), + industries: Array.from(industries).sort(compareLabels), + projects: Array.from(projects).sort(compareLabels), + organizations: Array.from(organizations).sort(compareLabels), tags: Array.from(tags.values()).sort((a, b) => tagKey(a).localeCompare(tagKey(b)), ), diff --git a/src/components/CaseStudies/index.js b/src/components/CaseStudies/index.js index 29961a38..292c99c3 100644 --- a/src/components/CaseStudies/index.js +++ b/src/components/CaseStudies/index.js @@ -4,6 +4,7 @@ import MirrorSyncStatus from '../shared/MirrorSyncStatus'; import { formatDate } from '../shared/formatDate'; import { useFilterToolbar } from '../shared/useFilterToolbar'; import FilterToolbar from '../shared/FilterToolbar'; +import { compareLabels } from '../shared/compareLabels'; import styles from './styles.module.css'; const DEFAULT_FILTERS = { query: '', project: '', industry: '', country: '' }; @@ -19,9 +20,9 @@ function useCaseStudyFilterOptions(caseStudies) { study.countries.forEach((c) => countries.add(c)); } return { - projects: Array.from(projects).sort(), - industries: Array.from(industries).sort(), - countries: Array.from(countries).sort(), + projects: Array.from(projects).sort(compareLabels), + industries: Array.from(industries).sort(compareLabels), + countries: Array.from(countries).sort(compareLabels), }; }, [caseStudies]); } diff --git a/src/components/MemberDirectory/hooks.js b/src/components/MemberDirectory/hooks.js index 7ded4afd..8b598614 100644 --- a/src/components/MemberDirectory/hooks.js +++ b/src/components/MemberDirectory/hooks.js @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { compareLabels } from '../shared/compareLabels'; export const MEMBERSHIP_FILTERS = [ { value: 'member', label: 'End User Members' }, @@ -18,8 +19,8 @@ export function useFilterOptions(members) { member.projects.forEach((p) => projects.add(p)); } return { - industries: Array.from(industries).sort(), - projects: Array.from(projects).sort(), + industries: Array.from(industries).sort(compareLabels), + projects: Array.from(projects).sort(compareLabels), membershipStatuses: MEMBERSHIP_FILTERS, }; }, [members]); diff --git a/src/components/shared/compareLabels.js b/src/components/shared/compareLabels.js new file mode 100644 index 00000000..ab47d6f9 --- /dev/null +++ b/src/components/shared/compareLabels.js @@ -0,0 +1,15 @@ +/** + * Comparator for the filter option lists on the case-study, + * reference-architecture and member catalogs. Project and industry names are + * published in their own casing (`etcd`, `cert-manager`, `gaming`), and the + * default `.sort()` orders by code point, which lists every lowercase label + * after every capitalised one. Locale collation orders letters before case, + * and pinning the locale keeps the order the same on every runtime. + * + * @param {string} a + * @param {string} b + * @returns {number} + */ +export function compareLabels(a, b) { + return a.localeCompare(b, 'en'); +} diff --git a/tests/architecture-filters-ui.test.mjs b/tests/architecture-filters-ui.test.mjs index 0443086a..8ff24014 100644 --- a/tests/architecture-filters-ui.test.mjs +++ b/tests/architecture-filters-ui.test.mjs @@ -82,6 +82,37 @@ test('option lists are de-duplicated and alphabetically sorted', () => { ]); }); +test('lowercase project, industry and organization names sort in alphabetical position', () => { + const { result } = renderHook(() => + useArchitectureFilterOptions([ + architecture({ + organization: 'Zeiss', + industries: ['optics', 'Manufacturing'], + projects: ['Talos Linux', 'containerd', 'cert-manager'], + }), + architecture({ + organization: 'adidas', + industries: ['gaming', 'Retail'], + projects: ['Cilium', 'stargz-snapshotter'], + }), + ]), + ); + assert.deepEqual(result.organizations, ['adidas', 'Zeiss']); + assert.deepEqual(result.industries, [ + 'gaming', + 'Manufacturing', + 'optics', + 'Retail', + ]); + assert.deepEqual(result.projects, [ + 'cert-manager', + 'Cilium', + 'containerd', + 'stargz-snapshotter', + 'Talos Linux', + ]); +}); + test('tags differing only in separators are listed once', () => { const { result } = renderHook(() => useArchitectureFilterOptions([ diff --git a/tests/case-studies.test.mjs b/tests/case-studies.test.mjs index 4947e0c5..c283095e 100644 --- a/tests/case-studies.test.mjs +++ b/tests/case-studies.test.mjs @@ -153,8 +153,10 @@ function clearButton(view) { ); } +// Project and industry names keep their published casing (etcd, gRPC), so the +// option order is collation order, not code-point order. function uniqueSorted(values) { - return [...new Set(values)].sort(); + return [...new Set(values)].sort((a, b) => a.localeCompare(b, 'en')); } test('the mirrored data set is non-empty, so these assertions are meaningful', () => { @@ -505,6 +507,41 @@ async function renderWithData(fixture) { } } +test('filter options list lowercase project and industry names in alphabetical position', async () => { + const view = await renderWithData({ + caseStudies: [ + study({ + id: 'a', + projects: ['Volcano', 'etcd', 'gRPC', 'Backstage'], + industries: ['Travel', 'iGaming', 'Transportation'], + countries: ['Canada', 'austria'], + }), + study({ + id: 'b', + projects: ['cert-manager', 'Cilium'], + industries: ['Financial'], + countries: ['Canada'], + }), + ], + }); + const options = (id) => optionValues(elementById(view.tree, id)); + assert.deepEqual(options('case-study-project'), [ + 'Backstage', + 'cert-manager', + 'Cilium', + 'etcd', + 'gRPC', + 'Volcano', + ]); + assert.deepEqual(options('case-study-industry'), [ + 'Financial', + 'iGaming', + 'Transportation', + 'Travel', + ]); + assert.deepEqual(options('case-study-country'), ['austria', 'Canada']); +}); + test('a mirror with no case-studies list renders an empty table', async () => { const view = await renderWithData({ generatedAt: '2026-01-02' }); assert.deepEqual(renderedTitles(view), []); diff --git a/tests/e2e/fixtures/accepted-regions.json b/tests/e2e/fixtures/accepted-regions.json index e0be201b..4b297999 100644 --- a/tests/e2e/fixtures/accepted-regions.json +++ b/tests/e2e/fixtures/accepted-regions.json @@ -8,7 +8,7 @@ "entries": [ { "file": "src/components/CaseStudies/index.js", - "span": "52:38-52:44", + "span": "53:38-53:44", "class": "data-shape-guard", "reason": "`|| []` fallback taken only when data/case-studies.json has no caseStudies array." }, diff --git a/tests/member-directory.test.mjs b/tests/member-directory.test.mjs index 74f5befb..7513a992 100644 --- a/tests/member-directory.test.mjs +++ b/tests/member-directory.test.mjs @@ -396,12 +396,15 @@ test('membership labels and filters distinguish every status', () => { test('useFilterOptions returns the sorted distinct industries and projects', () => { const { tree: result } = render(() => useFilterOptions(MEMBERS)); + // Published casing is kept (cert-manager, containerd), so the expected + // order is collation order rather than code-point order. + const collate = (a, b) => a.localeCompare(b, 'en'); const expectedIndustries = [ ...new Set(MEMBERS.flatMap((member) => member.industries)), - ].sort(); + ].sort(collate); const expectedProjects = [ ...new Set(MEMBERS.flatMap((member) => member.projects)), - ].sort(); + ].sort(collate); assert.deepEqual(result.industries, expectedIndustries); assert.deepEqual(result.projects, expectedProjects); @@ -413,6 +416,35 @@ test('useFilterOptions returns the sorted distinct industries and projects', () ); }); +test('useFilterOptions lists lowercase project and industry names in alphabetical position', () => { + const { tree: result } = render(() => + useFilterOptions([ + { + industries: ['Transport', 'gaming', 'Telecommunication'], + projects: ['Talos Linux', 'containerd', 'cert-manager', 'Cilium'], + }, + { + industries: ['optics', 'Financial'], + projects: ['stargz-snapshotter', 'Cilium'], + }, + ]), + ); + assert.deepEqual(result.industries, [ + 'Financial', + 'gaming', + 'optics', + 'Telecommunication', + 'Transport', + ]); + assert.deepEqual(result.projects, [ + 'cert-manager', + 'Cilium', + 'containerd', + 'stargz-snapshotter', + 'Talos Linux', + ]); +}); + test('useFilterOptions recomputes its options when the member list changes', () => { let input = [RICH_MEMBER]; const hook = renderHook(() => useFilterOptions(input)); @@ -621,12 +653,13 @@ test('each select names its facet and offers an "all" option first', () => { test('the industry and project selects list every distinct value, sorted', () => { const { view } = renderDirectory(); + const collate = (a, b) => a.localeCompare(b, 'en'); const industries = [ ...new Set(MEMBERS.flatMap((member) => member.industries)), - ].sort(); + ].sort(collate); const projects = [ ...new Set(MEMBERS.flatMap((member) => member.projects)), - ].sort(); + ].sort(collate); assert.deepEqual(optionValues(view.tree, 'member-industry'), [ '', diff --git a/tests/reference-architectures.test.mjs b/tests/reference-architectures.test.mjs index 3b035848..d0316f62 100644 --- a/tests/reference-architectures.test.mjs +++ b/tests/reference-architectures.test.mjs @@ -213,9 +213,12 @@ test('the toolbar is told how many results of how many total are shown', () => { 'an unfiltered catalog shows every entry', ); assert.equal(props.activeCount, 0); + // Collation order, so CERN follows Capital One rather than preceding it. assert.deepEqual( props.options.organizations, - [...new Set(catalog.map((a) => a.organization))].sort(), + [...new Set(catalog.map((a) => a.organization))].sort((a, b) => + a.localeCompare(b, 'en'), + ), ); });