Sort taxonomy: tokenize naturalCompare + match-case toggle + service link repoint#5368
Merged
norman-abramovitz merged 7 commits intoMay 24, 2026
Conversation
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.
norman-abramovitz
approved these changes
May 24, 2026
Contributor
norman-abramovitz
left a comment
There was a problem hiding this comment.
Look good to me
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownlocaleCompareexpressions with at least three different option sets. All swap to the sharednaturalCompare. Effective policy becomes uniform: case-insensitive, numeric-aware (soorg-2sorts beforeorg-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— rewritesnaturalComparefrom a bareIntl.Collator({numeric:true})to a token-walker. Splits each input on\d+runs and compares token-for-token (num↔num numerically, text↔text viaIntl.Collatorwith 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:Adds collection-aware
detectSortContextpre-pass that flipsstripSeparatorson when ≥30% of the values look like theletter–sep?–digitfamily. With the flag on, whitespace/_/- are removed before tokenization soOrg 3,Org_4,Org5sequence 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 setsort.field = '', causingViewPipelineto readrow[''] = undefinedfor every row and break orgs/spaces/etc lists. Now validated ass.field.length > 0.fix(services): repoint Name link from dead /services route to marketplace summary— the V3 cutover gave the Name column akind: 'link'target of/services/:type/:cnsi/:guid, a path that has never been registered inSERVICES_ROUTES(only:type/:endpointId/:serviceInstanceId/editand/detachexist). Clicking the name 404'd. Repoints to/marketplace/:cnsiGuid/:offeringGuid/summary(ServiceSummaryComponentunderSERVICE_CATALOG_ROUTES) for managed instances; user-provided instances returnnullso 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 inlinecontent_copyglyph withnavigator.clipboard.writeText.fix(cf-summary): switch CLI button to stratos-icons terminal glyph— Material Icons legacy font lacks theterminalligature so the CLI button rendered as an empty box. Switched to thestratos-iconsfont (which does have the glyph).Test plan
natural-sort.spec.ts(decision-table cells ×detectSortContextthresholds × end-to-end mixed-separator collection ordering)localhost:5440):