Skip to content

Sort taxonomy: tokenize naturalCompare + match-case toggle + service link repoint#5368

Merged
norman-abramovitz merged 7 commits into
cloudfoundry:developfrom
nabramovitz:feature/sort-taxonomy-slice-1-consolidate-natural-compare
May 24, 2026
Merged

Sort taxonomy: tokenize naturalCompare + match-case toggle + service link repoint#5368
norman-abramovitz merged 7 commits into
cloudfoundry:developfrom
nabramovitz:feature/sort-taxonomy-slice-1-consolidate-natural-compare

Conversation

@nabramovitz
Copy link
Copy Markdown
Contributor

Summary

Sort taxonomy slice — unifies list/dropdown sorts across the frontend onto one shared natural-compare, rewrites the comparator with a token-walker + decision table + collection-aware strip-separators heuristic, and adds a per-list match-case (Aa) toggle. Plus three regression repairs surfaced while testing.

Core sort work

  • refactor(sort): consolidate list/dropdown sorts on shared naturalCompare — 20 callsites across core + cloud-foundry + kubernetes were inlining their own localeCompare expressions with at least three different option sets. All swap to the shared naturalCompare. Effective policy becomes uniform: case-insensitive, numeric-aware (so org-2 sorts before org-10, capital-letter names don't jump to the top). Out-of-scope kept on bytewise compare: 3 entity-relations cache-key callsites.

  • refactor(natural-sort): tokenize + decision table + stripSeparators heuristic — rewrites naturalCompare from a bare Intl.Collator({numeric:true}) to a token-walker. Splits each input on \d+ runs and compares token-for-token (num↔num numerically, text↔text via Intl.Collator with locale-aware sensitivity, num↔text → num wins). Direction is baked into the comparator's return value. Missing-token decision table makes the bare-prefix vs numeric-siblings case explicit:

    MatchCase ON MatchCase OFF
    ASC smallest (-∞) empty (lex continuation)
    DESC largest (+∞) empty (lex continuation)

    Adds collection-aware detectSortContext pre-pass that flips stripSeparators on when ≥30% of the values look like the letter–sep?–digit family. With the flag on, whitespace/_/- are removed before tokenization so Org 3, Org_4, Org5 sequence together by their numeric token. 25 specs lock the decision-table cells, the stripSeparators behaviour, and the detection thresholds.

  • feat(list): per-list match-case toggle for natural sort — Aa icon in the list toolbar's sort area. Toggles the comparator's case-sensitivity at the per-list level (lifted to the service so the dropdown sort and the list sort share one setting).

Regression repairs (same PR per project convention)

  • fix(list-state-store): reject empty sort.field when restoring from storage — restoring from localStorage could set sort.field = '', causing ViewPipeline to read row[''] = undefined for every row and break orgs/spaces/etc lists. Now validated as s.field.length > 0.
  • fix(services): repoint Name link from dead /services route to marketplace summary — the V3 cutover gave the Name column a kind: 'link' target of /services/:type/:cnsi/:guid, a path that has never been registered in SERVICES_ROUTES (only :type/:endpointId/:serviceInstanceId/edit and /detach exist). Clicking the name 404'd. Repoints to /marketplace/:cnsiGuid/:offeringGuid/summary (ServiceSummaryComponent under SERVICE_CATALOG_ROUTES) for managed instances; user-provided instances return null so signal-list renders the name as plain text. Verified in browser: managed → offering summary, UPS → plain text.
  • refactor(metadata-item): render copy as inline glyph after the label<app-copy-to-clipboard> host was 0-width because children were absolute-positioned, so the copy icon appeared as a "misplaced subscript" next to metadata labels. Now an inline content_copy glyph with navigator.clipboard.writeText.
  • fix(cf-summary): switch CLI button to stratos-icons terminal glyph — Material Icons legacy font lacks the terminal ligature so the CLI button rendered as an empty box. Switched to the stratos-icons font (which does have the glyph).

Test plan

  • vitest: 25/25 natural-sort.spec.ts (decision-table cells × detectSortContext thresholds × end-to-end mixed-separator collection ordering)
  • Local browser verification (Playwright vs localhost:5440):
    • Services wall + per-CF Services tab → Name links resolve to marketplace offering summary; UPS instances render as plain text
    • Match-case toggle persists per list, flips comparator sensitivity at runtime
    • Copy icon renders inline next to metadata labels
    • CF summary CLI button shows terminal glyph
  • Full make gate (lint + vitest) — ran green during session; CI gate is authoritative
  • E2E — not required for this slice, no e2e selectors changed

Single source of truth for natural string comparison already lived in
@stratosui/core (`naturalCompare` over Intl.Collator with numeric: true,
sensitivity: 'base'). Most list/dropdown/tab/cell sorts were inlining
their own localeCompare expressions instead of using it, with at least
three different option sets across the 20 callsites — drift now caught.

Effective sort policy becomes uniform: case-insensitive, numeric-aware
(so "org-2" sorts before "org-10", and capital-letter names don't jump
to the top).

Touched (20 files):
- core: backup-endpoints, dashboard-base, endpoints-signal-config
- cloud-foundry: view-pipeline (default sort), cloud-foundry component,
  cf-roles, manage-users-confirm, select-plan-step, select-service,
  cf-apps/cf-routes/cf-users/cf-audit-events/cf-service-instances
  signal-config services
- kubernetes: kubernetes-tab-base, kube-config-auth, kubernetes-pod-
  containers, helm-release-tab-base, helm-release-summary-tab,
  monocular-charts-signal-config

Out of scope (3 callsites kept on bytewise localeCompare):
- entity-relations/{,signal/}*.tree.ts — sort feeds cache-key
  generation; output must stay deterministic and byte-stable across
  case variants.
- stratos-diagnostics.service — diagnostic snapshot ordering; keep
  byte-stable for diff-friendliness.

No new tests; existing list-config + view-pipeline specs continue to
exercise the sort path through the shared utility.

First slice of the sort-taxonomy feature workstream. Follow-ups:
rich-content dropdown migration + per-list case-toggle, then segmented
cf/org/space search (stratos#5361).
…orage

ViewPipeline's sortedItems reads `row[spec.field]`. When `spec.field` is
the empty string, every row's extracted value is `undefined`, all rows
compare equal, and Array.sort returns insertion order — visually
indistinguishable from "no sort applied", with the dropdown showing a
blank value instead of the bound column.

Observed via the orgs list during sort-sweep verification: a prior
session had persisted `{field:"", direction:"asc"}` to localStorage
under stratos.list-state.v1.cf-orgs (provenance unknown — no current
code path produces it). The `isSort()` schema check accepted it
(`typeof field === 'string'` is true for ""), so `bind()` restored the
corrupt state instead of falling back to the caller's named defaults.

Tightened `isSort()` to also require a non-empty field. Stored sort
entries with an empty field now fail validation, `bind()` returns null
from read(), and the caller's `sort: [{field:'name',direction:'asc'},
{field:'name',direction:'asc'}]` defaults take effect. Heals stale
localStorage automatically — no user action required.

Discovered while verifying the natural-compare sort sweep (this same
PR). Filed as a follow-up commit per "fix regressions where you find
them" rule rather than a separate PR.
Adds a runtime override so users can flip a list's sort between the
default case-insensitive natural mode and a case-sensitive natural mode,
without changing the field being sorted on. Lives next to the existing
sort-direction button in the signal-list toolbar; persisted alongside
the rest of the sort spec in the list-state store.

- naturalCompare: optional `caseSensitive` 3rd arg switches between two
  Intl.Collator instances (sensitivity: 'base' vs 'variant'). Default
  unchanged.
- SignalListSort + SortSpec: optional `caseSensitive` field, undefined
  treated as false.
- ViewPipeline (cloud-foundry + the core endpoints-page sibling copy):
  pass spec.caseSensitive into naturalCompare so the toggle reaches the
  comparator without a registry change.
- signal-list.component: new `toggleSortCaseSensitive()` method; "Aa"
  pill button next to the direction toggle, aria-pressed mirrors the
  active state, primary-tinted when on.
- list-state-store isSort(): accept the new optional field; previous
  reject-empty-field rule retained.

No code path produces caseSensitive=true by default — existing lists
keep the case-insensitive natural sort introduced earlier in this PR
unless a user clicks the toggle.
The CLI button next to the page-sub-nav rendered as literal text
"terminal" because the legacy Material Icons font (loaded from
fonts.googleapis.com/icon?family=Material+Icons) lacks that ligature —
`terminal` only exists in the newer Material Symbols set, which the
project doesn't load for inline material-icons spans.

stratos-icons (project-shipped custom font) DOES have a `terminal`
glyph. Same swap that kubernetes-summary uses (it already specified
both classes). Adds `stratos-icons` alongside `material-icons` so the
class override picks the right font.
Previously the copy button was rendered via <app-copy-to-clipboard>
inside the label row. That component lays its idle + success icons
out as `position: absolute right-0 top-0` inside a 0-width
inline-flex host, so on metadata-item — where the label is short and
the value can be long — the absolutely-positioned 24px material-icon
appeared to drift downward into the value row, looking like a
"misplaced subscript" next to the label rather than a control on
the label itself.

Replaced the child-component reference with a simple inline span
right after the label text (two non-breaking spaces, then a 14px
content_copy glyph), and added a tiny copyToClipboard() method on
MetadataItemComponent backed by navigator.clipboard.writeText with
the legacy execCommand fallback for non-secure-context dev URLs.
Brief 1.2s swap to a check_circle glyph confirms a successful copy.

The shared CopyToClipboardComponent is still used by code-block and
endpoint-card, both of which want the larger absolute-positioned
treatment — only metadata-item changes here. The mr-7 spacer on the
Instance Address value (a workaround for the old absolute-positioned
copy bleeding rightward) is removed.
…euristic

Replace the bare `Intl.Collator({numeric:true})` callsite with a
token-walker that splits each input on `\d+` runs and compares
token-for-token (num↔num numerically, text↔text via
Intl.Collator with locale-aware sensitivity, num↔text →
num wins).

Direction is baked into the comparator's return value — callers
pass `direction` and stop multiplying by their own sign.

Missing-token decision table makes the bare-prefix vs numeric-
siblings case explicit:

                 MatchCase ON       MatchCase OFF
     ASC         smallest (-∞)      empty (lex continuation)
     DESC        largest  (+∞)      empty (lex continuation)

Adds collection-aware `detectSortContext` pre-pass that flips
`stripSeparators` on when ≥30% of the values look like the
`letter–sep?–digit` family. With the flag on, whitespace/_/-
are removed before tokenization so `Org 3`, `Org_4`, `Org5`
sequence together by their numeric token.

ViewPipeline + endpoints-signal-config call `detectSortContext`
once per sort and pass the result into `naturalCompare`. Direct
callsites (small ASC dropdowns) keep using the comparator with
defaults — the pre-pass is opt-in.

25 specs lock the decision table cells, the stripSeparators
behaviour, and the detection thresholds. Design doc in
obsidian-knowledge-store/stratos/docs/2026-05-23-natural-sort.md.
…lace summary

The V3 cutover gave the Name column on both the global Services wall
and the per-CF Services tab a `kind: 'link'` target of
`/services/:type/:cnsi/:guid` — a path that has never been registered
in SERVICES_ROUTES (only `:type/:endpointId/:serviceInstanceId/edit`
and `/detach` exist). Clicking the name 404'd.

Repoint to `/marketplace/:cnsiGuid/:offeringGuid/summary` for managed
instances — the existing ServiceSummaryComponent under
SERVICE_CATALOG_ROUTES is the right destination for "tell me about
this service instance" (description, broker, available, shareable,
plans, recently-updated instances). User-provided instances have no
offering chain, so the link lambda returns `null` and signal-list
renders the name as plain text.

The legacy V2 list-config-base showed the name as plain text and the
V2 card linked the offering name (not the instance name) to the same
marketplace summary — so this restores the destination, with the
affordance moved to where the cutover put it.

The offering chain (`si.servicePlan?.serviceOffering?.guid`) is
already populated on StServiceInstance at list-load level — the
existing Service column reads `serviceOffering.name` from the same
path, so no extra fetch is needed.
Copy link
Copy Markdown
Contributor

@norman-abramovitz norman-abramovitz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look good to me

@norman-abramovitz norman-abramovitz merged commit c9ac1ed into cloudfoundry:develop May 24, 2026
12 checks passed
@nabramovitz nabramovitz deleted the feature/sort-taxonomy-slice-1-consolidate-natural-compare branch May 24, 2026 03:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants