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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Added

- Manage Teams can find a person by name: type part of a name to highlight
everyone who matches, on teams and in Unassigned, and press Enter to jump from
one to the next. Accents and case do not matter.
- Manage Teams makes unsaved changes hard to lose: a bar at the top stays in
view while you scroll and shows what is unsaved with Save and Discard, the
people and teams that changed are marked until saved, and following a link
Expand Down
72 changes: 72 additions & 0 deletions components/frontend/src/lib/utils/personSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest"
import { nameSegments, stepMatch } from "./personSearch"

/** The name with each marked piece in brackets, for readable expectations. */
const marked = (name: string, query: string) =>
nameSegments(name, query)
?.map((s) => (s.hit ? `[${s.text}]` : s.text))
.join("") ?? null

describe("nameSegments", () => {
it("marks a match anywhere in the name", () => {
expect(marked("Joana Meier", "ana")).toBe("Jo[ana] Meier")
expect(marked("Joana Meier", "meier")).toBe("Joana [Meier]")
})

it("ignores case", () => {
expect(marked("Joana Meier", "JOANA")).toBe("[Joana] Meier")
})

it("ignores accents on either side, marking the name as written", () => {
expect(marked("José Núñez", "jose")).toBe("[José] Núñez")
expect(marked("Jose Nunez", "josé")).toBe("[Jose] Nunez")
expect(marked("José Núñez", "nunez")).toBe("José [Núñez]")
})

it("keeps the marks in place when the name is already decomposed", () => {
// "é" as "e" plus a combining accent: two code units, one letter.
const name = "José Meier"
expect(marked(name, "jose")).toBe("[José] Meier")
expect(marked(name, "meier")).toBe("José [Meier]")
})

it("marks every occurrence", () => {
expect(marked("Anna Hanna", "an")).toBe("[An]na H[an]na")
})

it("ignores the spaces around the query, not inside it", () => {
expect(marked("Joana Meier", " ana ")).toBe("Jo[ana] Meier")
expect(marked("Joana Meier", "a m")).toBe("Joan[a M]eier")
expect(nameSegments("Joana Meier", "joanam")).toBeNull()
})

it("is null for a blank query or no match", () => {
expect(nameSegments("Joana Meier", "")).toBeNull()
expect(nameSegments("Joana Meier", " ")).toBeNull()
expect(nameSegments("Joana Meier", "xyz")).toBeNull()
})
})

describe("stepMatch", () => {
const ids = ["a", "b", "c"]

it("steps forward and back", () => {
expect(stepMatch(ids, "a", 1)).toBe("b")
expect(stepMatch(ids, "b", -1)).toBe("a")
})

it("wraps at either end", () => {
expect(stepMatch(ids, "c", 1)).toBe("a")
expect(stepMatch(ids, "a", -1)).toBe("c")
})

it("starts at the first going forward and the last going back", () => {
expect(stepMatch(ids, null, 1)).toBe("a")
expect(stepMatch(ids, null, -1)).toBe("c")
expect(stepMatch(ids, "gone", 1)).toBe("a")
})

it("is null when nothing matches", () => {
expect(stepMatch([], "a", 1)).toBeNull()
})
})
110 changes: 110 additions & 0 deletions components/frontend/src/lib/utils/personSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* "Find a person" on the team-assignment page: which names match what was
* typed, which letters to mark, and stepping from one match to the next.
*
* Kept out of the page so it can be tested directly: the page only keeps the
* query, draws the marks and scrolls.
*
* A search highlights and never hides — unlike the pool filters it does not
* even reorder, so nobody moves while the organizer is looking for them.
*/

/** A piece of a name, marked when it is part of a match. */
export type NameSegment = { text: string; hit: boolean }

/**
* Lower case without accents, so "jose" finds "José" and "JOSE".
* Decomposing splits "é" into "e" plus a combining accent; dropping the
* combining marks leaves the plain letter.
*/
function fold(text: string): string {
return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase()
}

/**
* The name split into marked and unmarked pieces, or null when the query is
* blank or not in the name. Matches anywhere in the name, ignoring case,
* accents and the spaces around the query, and marks every occurrence.
*
* The name is folded one character at a time so each folded letter knows
* which original characters it came from: folding can change the length
* (an "é" written as "e" plus a combining accent folds to one letter), and
* the marks belong on the name as written.
*/
export function nameSegments(
name: string,
query: string,
): NameSegment[] | null {
const needle = fold(query.trim())
if (needle === "") return null

let folded = ""
const starts: number[] = []
const ends: number[] = []
let at = 0
for (const char of name) {
const f = fold(char)
// A lone combining accent folds to nothing; it belongs to the letter
// before it, so a mark ending on that letter takes it in too.
for (let i = ends.length - 1; f === "" && i >= 0 && ends[i] === at; i--) {
ends[i] = at + char.length
}
folded += f
for (let i = 0; i < f.length; i++) {
starts.push(at)
ends.push(at + char.length)
}
at += char.length
}

const ranges: [number, number][] = []
for (
let i = folded.indexOf(needle);
i !== -1;
i = folded.indexOf(needle, i + needle.length)
) {
const start = starts[i]
const end = ends[i + needle.length - 1]
// Always set — `folded` and both maps grow together — but the compiler
// cannot know that.
if (start === undefined || end === undefined) continue
const last = ranges[ranges.length - 1]
// Two matches can end and start inside the same original character.
if (last && start <= last[1]) last[1] = Math.max(last[1], end)
else ranges.push([start, end])
}
if (ranges.length === 0) return null

const segments: NameSegment[] = []
let from = 0
for (const [start, end] of ranges) {
if (start > from)
segments.push({ text: name.slice(from, start), hit: false })
segments.push({ text: name.slice(start, end), hit: true })
from = end
}
if (from < name.length) segments.push({ text: name.slice(from), hit: false })

return segments
}

/**
* The match one step forward (+1) or back (-1) from the current one, wrapping
* at either end. With no current match — or one that is gone — forward starts
* at the first match and back at the last. Null when nothing matches.
*/
export function stepMatch(
ids: readonly string[],
currentId: string | null,
step: 1 | -1,
): string | null {
const at = currentId === null ? -1 : ids.indexOf(currentId)
const next =
at === -1
? step === 1
? ids[0]
: ids.at(-1)
: ids[(at + step + ids.length) % ids.length]

return next ?? null
}
Loading
Loading