Skip to content

Commit 1cf5d2d

Browse files
committed
Add sortable concept entries (closes #336)
Concepts in a ConceptScheme tree and members of a skos:Collection were rendered in source-TTL order, which is unfriendly when skimming a vocab. Add a config option `sortBy: "prefLabel" | "notation" | "none"` (default "prefLabel") that seeds an initial order, plus a Sort selector in TreeControls so visitors can switch at runtime. Sorting is locale- and numerically aware via Intl.Collator (e.g. "Item 2" before "Item 10", "1.1" < "1.2" < "1.10"). Items missing a notation sink to the bottom when sorting by notation, tie-broken by prefLabel. TreeControls now also renders for flat schemes so the sort selector is reachable; Collapse/Expand are hidden when there's no nesting. Note: existing sites will reorder alphabetically by default after upgrade. Set `sortBy: "none"` in config.yaml to preserve TTL order.
1 parent 287ee53 commit 1cf5d2d

16 files changed

Lines changed: 492 additions & 37 deletions

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ You can configure the following settings:
144144
- Colors
145145
- Fonts
146146
- Searchable Fields
147+
- Sort order
147148

148149
The settings are explained in the following sections.
149150

@@ -166,6 +167,26 @@ Then provide `http://my-awesome-domain.org` as `custom_domain` in your `config.y
166167

167168
If `true` (default) the build process will stop if a validation error occures.
168169

170+
### Sort order
171+
172+
Concept entries inside a ConceptScheme tree (and members of a `skos:Collection`)
173+
can be sorted at display time. Set the initial default in `config.yaml`:
174+
175+
```yaml
176+
sortBy: "prefLabel" # one of: "prefLabel" | "notation" | "none"
177+
```
178+
179+
- `"prefLabel"` (default) — alphabetical by `skos:prefLabel` in the currently
180+
selected language. Locale-aware via `Intl.Collator` and numerically aware
181+
(so `Item 2` comes before `Item 10`).
182+
- `"notation"` — sorted by `skos:notation`. Numeric and dotted numeric
183+
notations sort naturally (`1.1`, `1.2`, `1.10`). Items without a notation
184+
fall to the bottom and are tie-broken by `prefLabel`.
185+
- `"none"` — preserves the order in which entries appear in the source TTL.
186+
187+
Visitors can switch between these options at runtime via the **Sort** selector
188+
that lives next to the Collapse / Expand controls in the tree sidebar.
189+
169190
### UI
170191

171192
The following customizations can be made:

config.default.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
tokenizer: "full" # strict, forward, reverse, full
44
custom_domain: ""
55
fail_on_validation: true
6+
sortBy: "prefLabel" # one of: "prefLabel" | "notation" | "none" — initial default for the tree/collection sort selector
67
searchableAttributes:
78
- "prefLabel" # you should not delete this one
89
- "notation"

cypress/e2e/sort.cy.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
describe("Sort entries", () => {
2+
// Top concepts from systematik.ttl, in source (TTL) order:
3+
// n1 Geisteswissenschaften, n2 Sport, n3 Rechts-..., n4 Mathematik,
4+
// n5 Humanmedizin, n7 Agrar-..., n8 Ingenieurwissenschaften, n9 Kunst
5+
// Notations: 1, 2, 3, 4, 5, 7, 8, 9
6+
// Alphabetical (de): Agrar-, Geisteswissenschaften, Humanmedizin,
7+
// Ingenieurwissenschaften, Kunst, Mathematik, Rechts-, Sport
8+
const url = "/w3id.org/kim/hochschulfaechersystematik/scheme.html?lang=de"
9+
10+
it("renders the sort selector with prefLabel as default", () => {
11+
cy.visit(url)
12+
cy.findByRole("combobox", { name: /sort entries by/i })
13+
.should("exist")
14+
.and("have.value", "prefLabel")
15+
})
16+
17+
it("default order is alphabetical by prefLabel", () => {
18+
cy.visit(url)
19+
// First top-level link in the tree should start with "Agrar"
20+
cy.get(".concepts > nav, .concepts").should("exist")
21+
cy.get(".concepts ul li > div > a")
22+
.first()
23+
.invoke("text")
24+
.should("match", /Agrar/)
25+
})
26+
27+
it("switching to Notation reorders the tree by notation", () => {
28+
cy.visit(url)
29+
cy.findByRole("combobox", { name: /sort entries by/i }).select("notation")
30+
// Notation 1 → Geisteswissenschaften
31+
cy.get(".concepts ul li > div > a")
32+
.first()
33+
.invoke("text")
34+
.should("match", /Geisteswissenschaften/)
35+
})
36+
37+
it("switching to Source order preserves TTL order", () => {
38+
cy.visit(url)
39+
cy.findByRole("combobox", { name: /sort entries by/i }).select("none")
40+
cy.get(".concepts ul li > div > a")
41+
.first()
42+
.invoke("text")
43+
.should("match", /Geisteswissenschaften/)
44+
})
45+
46+
it("sort selector is visible even on flat schemes (no hierarchy)", () => {
47+
cy.visit("/purl.org/dcx/lrmi-vocabs/interactivityType/index.html", {
48+
onBeforeLoad(win) {
49+
Object.defineProperty(win.navigator, "language", { value: "en-EN" })
50+
},
51+
})
52+
cy.findByRole("combobox", { name: /sort entries by/i }).should("exist")
53+
// Collapse/Expand should still be hidden on flat schemes
54+
cy.contains("Collapse").should("not.exist")
55+
cy.contains("Expand").should("not.exist")
56+
})
57+
})

gatsby-config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = {
1616
searchableAttributes: config.searchableAttributes,
1717
customDomain: config.customDomain,
1818
failOnValidation: config.failOnValidation,
19+
sortBy: config.sortBy,
1920
},
2021
pathPrefix: `${process.env.BASEURL || ""}`,
2122
plugins: [

src/components/Collection.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { Link } from "gatsby"
22
import { i18n, getFilePath } from "../common"
33
import JsonLink from "./JsonLink"
44
import { useSkoHubContext } from "../context/Context"
5+
import { getConfigAndConceptSchemes } from "../hooks/configAndConceptSchemes"
6+
import { sortConcepts } from "../sortConcepts"
57
import { useEffect, useState } from "react"
68

79
const Collection = ({ pageContext: { node: collection, customDomain } }) => {
810
const { data } = useSkoHubContext()
11+
const { config } = getConfigAndConceptSchemes()
912
const [language, setLanguage] = useState("")
1013

1114
useEffect(() => {
@@ -14,13 +17,16 @@ const Collection = ({ pageContext: { node: collection, customDomain } }) => {
1417
}
1518
}, [data?.selectedLanguage])
1619

20+
const sortBy = data?.sortBy ?? config?.sortBy ?? "prefLabel"
21+
const sortedMembers = sortConcepts(collection.member, sortBy, language)
22+
1723
return (
1824
<div>
1925
<h1>{i18n(language)(collection.prefLabel)}</h1>
2026
<h2>{collection.id}</h2>
2127
<JsonLink to={getFilePath(collection.id, "json", customDomain)} />
2228
<ul>
23-
{collection.member.map((member) => (
29+
{sortedMembers.map((member) => (
2430
<li key={member.id}>
2531
<Link to={getFilePath(member.id, `html`, customDomain)}>
2632
{i18n(language)(member.prefLabel) ||

src/components/TreeControls.jsx

Lines changed: 73 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,89 @@
1+
import { useEffect } from "react"
12
import { css } from "@emotion/react"
3+
import { getConfigAndConceptSchemes } from "../hooks/configAndConceptSchemes"
4+
import { useSkoHubContext } from "../context/Context"
25

36
const style = css`
47
display: flex;
8+
flex-wrap: wrap;
9+
gap: 10px;
510
margin-bottom: 10px;
11+
align-items: center;
612
713
input[type="button"] {
814
flex: 1;
915
padding: 5px 10px;
16+
min-width: 80px;
17+
}
18+
19+
.sortControl {
20+
display: flex;
21+
align-items: center;
22+
gap: 6px;
23+
margin-left: auto;
24+
font-size: 0.9em;
1025
11-
&:first-of-type {
12-
margin-right: 10px;
26+
select {
27+
padding: 4px 6px;
1328
}
1429
}
1530
`
1631

17-
const TreeControls = () => (
18-
<div className="TreeControls" css={style}>
19-
<input
20-
type="button"
21-
className="inputStyle"
22-
value="Collapse"
23-
onClick={() => {
24-
;[...document.querySelectorAll(".treeItemIcon")].forEach((el) => {
25-
el.classList.add("collapsed")
26-
el.setAttribute("aria-expanded", false)
27-
})
28-
}}
29-
/>
30-
<input
31-
type="button"
32-
className="inputStyle"
33-
value="Expand"
34-
onClick={() => {
35-
;[...document.querySelectorAll(".collapsed")].forEach((el) => {
36-
el.classList.remove("collapsed")
37-
el.setAttribute("aria-expanded", true)
38-
})
39-
}}
40-
/>
41-
</div>
42-
)
32+
const TreeControls = ({ hasNesting = true }) => {
33+
const { config } = getConfigAndConceptSchemes()
34+
const { data, updateState } = useSkoHubContext()
35+
36+
// Initialise sortBy from config on first render if it hasn't been set yet.
37+
useEffect(() => {
38+
if (data.sortBy == null) {
39+
updateState({ ...data, sortBy: config?.sortBy || "prefLabel" })
40+
}
41+
}, [data.sortBy, config?.sortBy])
42+
43+
const currentSort = data.sortBy ?? config?.sortBy ?? "prefLabel"
44+
45+
return (
46+
<div className="TreeControls" css={style}>
47+
{hasNesting && (
48+
<>
49+
<input
50+
type="button"
51+
className="inputStyle"
52+
value="Collapse"
53+
onClick={() => {
54+
;[...document.querySelectorAll(".treeItemIcon")].forEach((el) => {
55+
el.classList.add("collapsed")
56+
el.setAttribute("aria-expanded", false)
57+
})
58+
}}
59+
/>
60+
<input
61+
type="button"
62+
className="inputStyle"
63+
value="Expand"
64+
onClick={() => {
65+
;[...document.querySelectorAll(".collapsed")].forEach((el) => {
66+
el.classList.remove("collapsed")
67+
el.setAttribute("aria-expanded", true)
68+
})
69+
}}
70+
/>
71+
</>
72+
)}
73+
<label className="sortControl">
74+
Sort:
75+
<select
76+
aria-label="Sort entries by"
77+
value={currentSort}
78+
onChange={(e) => updateState({ ...data, sortBy: e.target.value })}
79+
>
80+
<option value="prefLabel">Label</option>
81+
<option value="notation">Notation</option>
82+
<option value="none">Source order</option>
83+
</select>
84+
</label>
85+
</div>
86+
)
87+
}
4388

4489
export default TreeControls

src/components/nestedList.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Link as GatsbyLink } from "gatsby"
55

66
import { getConfigAndConceptSchemes } from "../hooks/configAndConceptSchemes"
77
import { useSkoHubContext } from "../context/Context"
8+
import { sortConcepts } from "../sortConcepts"
89

910
const getNestedItems = (item) => {
1011
let ids = [item.id]
@@ -151,7 +152,8 @@ const NestedList = ({
151152
}
152153
}
153154

154-
const filteredItems = getFilteredItems()
155+
const sortBy = data?.sortBy ?? config?.sortBy ?? "prefLabel"
156+
const filteredItems = sortConcepts(getFilteredItems(), sortBy, language)
155157
const t = i18n(language)
156158

157159
const isExpanded = (item, truthy, falsy) => {

src/context/Context.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const defaultState = {
55
selectedLanguage: "",
66
conceptSchemeLanguages: [],
77
indexPage: false,
8+
sortBy: null,
89
}
910
const Context = createContext(defaultState)
1011

src/hooks/configAndConceptSchemes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export const getConfigAndConceptSchemes = () => {
8585
searchableAttributes
8686
customDomain
8787
failOnValidation
88+
sortBy
8889
}
8990
}
9091
allConceptScheme {

src/sortConcepts.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const { i18n } = require("./common")
2+
3+
/**
4+
* Stable, locale-aware sort for SKOS-shaped items (concepts, collection
5+
* members, top concepts).
6+
*
7+
* @param {Array} items
8+
* @param {"prefLabel" | "notation" | "none" | null | undefined} sortBy
9+
* @param {string} language - BCP-47 language tag used both to pick the
10+
* prefLabel string and as the locale for Intl.Collator
11+
* @returns {Array} a new array (input is not mutated). When sortBy is "none"
12+
* or falsy, the original array is returned unchanged.
13+
*/
14+
const sortConcepts = (items, sortBy, language) => {
15+
if (!Array.isArray(items) || items.length < 2) return items
16+
if (!sortBy || sortBy === "none") return items
17+
18+
const t = i18n(language)
19+
const collator = new Intl.Collator(language || undefined, {
20+
numeric: true,
21+
sensitivity: "base",
22+
})
23+
24+
const labelOf = (item) => t(item.prefLabel) || item.id || ""
25+
const notationOf = (item) =>
26+
Array.isArray(item.notation) && item.notation.length > 0
27+
? String(item.notation[0])
28+
: ""
29+
30+
const compareByLabel = (a, b) => collator.compare(labelOf(a), labelOf(b))
31+
32+
const compareByNotation = (a, b) => {
33+
const na = notationOf(a)
34+
const nb = notationOf(b)
35+
if (na && nb) return collator.compare(na, nb)
36+
// Items without a notation sink below items that have one; among the
37+
// notation-less, fall back to prefLabel so the order is still meaningful.
38+
if (na && !nb) return -1
39+
if (!na && nb) return 1
40+
return compareByLabel(a, b)
41+
}
42+
43+
const compare = sortBy === "notation" ? compareByNotation : compareByLabel
44+
return [...items].sort(compare)
45+
}
46+
47+
module.exports = { sortConcepts }

0 commit comments

Comments
 (0)