From 19861a052e0015f95dabc5fb12902aecbf948b86 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 05:33:17 -0700 Subject: [PATCH 1/7] refactor(sort): consolidate list/dropdown sorts on shared naturalCompare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../cf/cloud-foundry/cloud-foundry.component.ts | 3 ++- .../cf/users/manage-users/cf-roles.service.ts | 4 +++- .../manage-users-confirm.component.ts | 7 ++++--- .../src/services/data-sources/view-pipeline.ts | 12 +++++------- .../select-plan-step/select-plan-step.component.ts | 3 ++- .../list-types/app/cf-apps-signal-config.service.ts | 6 +++--- .../cf-audit-events-signal-config.service.ts | 10 +++++----- .../route/cf-routes-signal-config.service.ts | 10 +++++----- .../cf-service-instances-signal-config.service.ts | 10 +++++----- .../user/cf-users-signal-config.service.ts | 10 +++++----- .../select-service/select-service.component.ts | 4 ++-- .../dashboard-base/dashboard-base.component.ts | 3 ++- .../backup-endpoints/backup-endpoints.component.ts | 3 ++- .../endpoints-signal-config.service.ts | 5 ++--- .../monocular-charts-signal-config.service.ts | 6 +++--- .../kube-config-auth.helper.ts | 3 ++- .../kubernetes-tab-base.component.ts | 3 ++- .../kubernetes-pod-containers.component.ts | 4 ++-- .../helm-release-tab-base.component.ts | 4 +++- .../helm-release-summary-tab.component.ts | 8 ++++---- 20 files changed, 63 insertions(+), 55 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry/cloud-foundry.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry/cloud-foundry.component.ts index bea019f4eb..4b7e551875 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry/cloud-foundry.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/cloud-foundry/cloud-foundry.component.ts @@ -17,6 +17,7 @@ import { SignalListComponent, SignalListConfig, SignalListSort, + naturalCompare, } from '@stratosui/core'; import type { EndpointModel } from '@stratosui/store'; @@ -80,7 +81,7 @@ export class CloudFoundryComponent { if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; - return String(av).localeCompare(String(bv), undefined, { sensitivity: 'base', numeric: true }) * dir; + return naturalCompare(String(av), String(bv)) * dir; }); }); const pageSize: WritableSignal = signal(24); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts index 0e78d29448..eccd8c82ab 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/cf-roles.service.ts @@ -14,6 +14,8 @@ import { take, switchMap, } from 'rxjs/operators'; +import { naturalCompare } from '@stratosui/core'; + import { CurrentUserPermissionsService } from '../../../../../../core/src/core/permissions/current-user-permissions.service'; import { APIResource, EntityInfo } from '../../../../../../store/src/types/api.types'; import { IOrganization, ISpace } from '../../../../cf-api.types'; @@ -285,7 +287,7 @@ export class CfRolesService { catchError(() => of([] as APIResource[])), ); this.cfOrgs[cfGuid] = CfRolesService.filterEditableOrgOrSpace(this.userPerms, true, orgs$).pipe( - map(orgs => orgs.sort((a, b) => a.entity.name.localeCompare(b.entity.name))), + map(orgs => orgs.sort((a, b) => naturalCompare(a.entity.name, b.entity.name))), publishReplay(1), refCount() ); diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-confirm/manage-users-confirm.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-confirm/manage-users-confirm.component.ts index 8804b8e38b..fbb3d49472 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-confirm/manage-users-confirm.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/users/manage-users/manage-users-confirm/manage-users-confirm.component.ts @@ -9,7 +9,8 @@ import { AppActionMonitorComponent, AppMonitorComponentTypes, ITableColumn, - ITableCellRequestMonitorIconConfig } from '@stratosui/core'; + ITableCellRequestMonitorIconConfig, + naturalCompare } from '@stratosui/core'; import { entityCatalog, APIResource } from '@stratosui/store'; import { UsersRolesClearUpdateState } from '../../../../../actions/users-roles.actions'; import { ChangeCfUserRole } from '../../../../../actions/users.actions'; @@ -169,7 +170,7 @@ export class UsersRolesConfirmComponent implements OnInit, AfterContentInit { username: ManageUsersSetUsernamesHelper.usernameFromGuid(change.userGuid), roleName: this.fetchRoleName(change.role, !change.spaceGuid) })) - .sort((a, b) => a.username.localeCompare(b.username)), + .sort((a, b) => naturalCompare(a.username, b.username)), ) ); const changesViaUserGuid = this.updateChanges.pipe( @@ -183,7 +184,7 @@ export class UsersRolesConfirmComponent implements OnInit, AfterContentInit { username: this.fetchUsername(change.userGuid, users), roleName: this.fetchRoleName(change.role, !change.spaceGuid) })) - .sort((a, b) => a.username.localeCompare(b.username)) + .sort((a, b) => naturalCompare(a.username, b.username)) ) ); this.changes$ = this.setUsernames ? changesViaUsername : changesViaUserGuid; diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts index 9c9057da2c..fc40092d38 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts @@ -1,5 +1,7 @@ import { Signal, computed } from '@angular/core'; +import { naturalCompare } from '@stratosui/core'; + export interface SortSpec { // Property name on T to sort by. Declared as string (not keyof T) so // the shape is compatible with SignalListComponent's SignalListSort @@ -57,14 +59,10 @@ export class ViewPipeline { if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * sign; } - // Natural string sort for string/string comparisons: - // - case-insensitive (orgs starting with capital letters don't - // jump to the top of the list) - // - numeric-aware (org_2 sorts before org_10, not after) - // Falls back to `<` / `>` for non-string / mixed types (dates as - // ISO strings still compare correctly under localeCompare). + // Natural string sort: case-insensitive + numeric-aware. + // Single source of truth lives in @stratosui/core/naturalCompare. if (typeof av === 'string' && typeof bv === 'string') { - return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * sign; + return naturalCompare(av, bv) * sign; } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts index da8412ae54..b823d6ea12 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, Injector, OnDestroy, ViewChild, Vie import { ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms'; import { CustomFormFieldComponent, MatLabelComponent } from '@stratosui/core'; import { CustomSelectComponent, CustomOptionComponent } from '@stratosui/core'; +import { naturalCompare } from '@stratosui/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { @@ -153,7 +154,7 @@ export class SelectPlanStepComponent implements OnDestroy { this.clearNoPlans(); } }), - map(plans => [...plans].sort((a, b) => this.getDisplayName(a).localeCompare(this.getDisplayName(b)))), + map(plans => [...plans].sort((a, b) => naturalCompare(this.getDisplayName(a), this.getDisplayName(b)))), publishReplay(1), refCount(), ); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts index 25c8bfc4e1..3771382780 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/app/cf-apps-signal-config.service.ts @@ -14,7 +14,7 @@ import { CloudFoundryService } from '../../../../data-services/cloud-foundry.ser import { writeWithJob } from '../../../../../services/async-jobs/write-with-job'; import type { StratosJob } from '../../../../../services/async-jobs/async-job.types'; import type { SignalListDropdownOption } from '@stratosui/core'; -import { ListStateStore } from '@stratosui/core'; +import { ListStateStore, naturalCompare } from '@stratosui/core'; @Injectable({ providedIn: 'root' }) export class CfAppsSignalConfigService { @@ -226,7 +226,7 @@ export class CfAppsSignalConfigService { } } const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; - const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b)); + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => naturalCompare(a, b)); for (const [guid, label] of sorted) opts.push({ label, value: guid }); return opts; }); @@ -247,7 +247,7 @@ export class CfAppsSignalConfigService { } } const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; - const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b)); + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => naturalCompare(a, b)); for (const [guid, label] of sorted) opts.push({ label, value: guid }); return opts; }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts index c2a778945b..f11685c665 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/cf-events/cf-audit-events-signal-config.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { SignalListDropdownOption } from '@stratosui/core'; -import { ListStateStore } from '@stratosui/core'; +import { ListStateStore, naturalCompare } from '@stratosui/core'; import { CnsiAuditEventsSource } from '../../../../../services/data-sources/cnsi-audit-events-source'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; @@ -59,7 +59,7 @@ export class CfAuditEventsSignalConfigService { readonly orgOptions: Signal = computed(() => { const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; const orgs = this.endpointDataService?.orgs() ?? []; - const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const sorted = [...orgs].sort((a, b) => naturalCompare(a.name, b.name)); for (const o of sorted) opts.push({ label: o.name, value: o.guid }); return opts; }); @@ -75,7 +75,7 @@ export class CfAuditEventsSignalConfigService { if (org) { const sorted = spaces .filter(s => s.orgGuid === org) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + .sort((a, b) => naturalCompare(a.name, b.name)); for (const s of sorted) opts.push({ label: s.name, value: s.guid }); return opts; } @@ -86,9 +86,9 @@ export class CfAuditEventsSignalConfigService { orgName: orgNameByGuid.get(s.orgGuid) ?? '', })); augmented.sort((a, b) => { - const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + const bySpace = naturalCompare(a.spaceName, b.spaceName); if (bySpace !== 0) return bySpace; - return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + return naturalCompare(a.orgName, b.orgName); }); for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); return opts; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts index f31f1b2606..4584d34915 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/route/cf-routes-signal-config.service.ts @@ -2,7 +2,7 @@ import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, eff import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { SignalListDropdownOption } from '@stratosui/core'; -import { ListStateStore } from '@stratosui/core'; +import { ListStateStore, naturalCompare } from '@stratosui/core'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; import { ViewPipeline, SortSpec } from '../../../../../services/data-sources/view-pipeline'; @@ -179,7 +179,7 @@ export class CfRoutesSignalConfigService { readonly orgOptions: Signal = computed(() => { const orgs = this.endpointDataService?.orgs() ?? []; const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; - const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const sorted = [...orgs].sort((a, b) => naturalCompare(a.name, b.name)); for (const o of sorted) { opts.push({ label: o.name, value: o.guid }); } @@ -198,7 +198,7 @@ export class CfRoutesSignalConfigService { if (org) { const sorted = spaces .filter(s => s.orgGuid === org) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + .sort((a, b) => naturalCompare(a.name, b.name)); for (const s of sorted) opts.push({ label: s.name, value: s.guid }); return opts; } @@ -209,9 +209,9 @@ export class CfRoutesSignalConfigService { orgName: orgNameByGuid.get(s.orgGuid) ?? '', })); augmented.sort((a, b) => { - const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + const bySpace = naturalCompare(a.spaceName, b.spaceName); if (bySpace !== 0) return bySpace; - return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + return naturalCompare(a.orgName, b.orgName); }); for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); return opts; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts index fefeee3fb4..1708fbf4cc 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/service-instance/cf-service-instances-signal-config.service.ts @@ -10,7 +10,7 @@ import type { EndpointDataService } from '../../../../../services/endpoint-data/ import type { StServiceInstance } from '../../../../../services/endpoint-data/stratos-types'; import { CloudFoundryService } from '../../../../data-services/cloud-foundry.service'; import type { SignalListDropdownOption } from '@stratosui/core'; -import { ListStateStore } from '@stratosui/core'; +import { ListStateStore, naturalCompare } from '@stratosui/core'; // Service instances list config — multi-CNSI by default (services-wall), // with optional space + type narrowing for the per-space tabs. @@ -166,7 +166,7 @@ export class CfServiceInstancesSignalConfigService { if (!seen.has(o.guid)) seen.set(o.guid, o.name); } } - const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b, undefined, { numeric: true })); + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => naturalCompare(a, b)); for (const [guid, label] of sorted) opts.push({ label, value: guid }); return opts; }); @@ -193,7 +193,7 @@ export class CfServiceInstancesSignalConfigService { if (!seen.has(s.guid)) seen.set(s.guid, s.name); } } - const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => a.localeCompare(b, undefined, { numeric: true })); + const sorted = Array.from(seen.entries()).sort(([, a], [, b]) => naturalCompare(a, b)); for (const [guid, label] of sorted) opts.push({ label, value: guid }); return opts; } @@ -210,9 +210,9 @@ export class CfServiceInstancesSignalConfigService { } const entries = Array.from(augmented.entries()); entries.sort(([, a], [, b]) => { - const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + const bySpace = naturalCompare(a.spaceName, b.spaceName); if (bySpace !== 0) return bySpace; - return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + return naturalCompare(a.orgName, b.orgName); }); for (const [guid, { spaceName, orgName }] of entries) { opts.push({ label: `${spaceName} - ${orgName}`, value: guid }); diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts index cefa2bd68b..2c496f5852 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/list/list-types/user/cf-users-signal-config.service.ts @@ -2,7 +2,7 @@ import { DestroyRef, Injectable, Injector, Signal, WritableSignal, computed, eff import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import type { SignalListDropdownOption } from '@stratosui/core'; -import { ListStateStore } from '@stratosui/core'; +import { ListStateStore, naturalCompare } from '@stratosui/core'; import { CfUserListDiagnosticsService } from '../../../../../services/diagnostics/cf-user-list-diagnostics.service'; import { EndpointDataRegistry } from '../../../../../services/endpoint-data/endpoint-data.registry'; import type { EndpointDataService } from '../../../../../services/endpoint-data/endpoint-data.service'; @@ -132,7 +132,7 @@ export class CfUsersSignalConfigService { readonly orgOptions: Signal = computed(() => { const opts: SignalListDropdownOption[] = [{ label: 'All', value: null }]; const orgs = this.endpointDataService?.orgs() ?? []; - const sorted = [...orgs].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const sorted = [...orgs].sort((a, b) => naturalCompare(a.name, b.name)); for (const o of sorted) opts.push({ label: o.name, value: o.guid }); return opts; }); @@ -148,7 +148,7 @@ export class CfUsersSignalConfigService { if (org) { const sorted = spaces .filter(s => s.orgGuid === org) - .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + .sort((a, b) => naturalCompare(a.name, b.name)); for (const s of sorted) opts.push({ label: s.name, value: s.guid }); return opts; } @@ -159,9 +159,9 @@ export class CfUsersSignalConfigService { orgName: orgNameByGuid.get(s.orgGuid) ?? '', })); augmented.sort((a, b) => { - const bySpace = a.spaceName.localeCompare(b.spaceName, undefined, { numeric: true }); + const bySpace = naturalCompare(a.spaceName, b.spaceName); if (bySpace !== 0) return bySpace; - return a.orgName.localeCompare(b.orgName, undefined, { numeric: true }); + return naturalCompare(a.orgName, b.orgName); }); for (const s of augmented) opts.push({ label: `${s.spaceName} - ${s.orgName}`, value: s.guid }); return opts; diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/select-service/select-service.component.ts b/src/frontend/packages/cloud-foundry/src/shared/components/select-service/select-service.component.ts index 8a4c2f08cf..3555ac8801 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/select-service/select-service.component.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/components/select-service/select-service.component.ts @@ -15,7 +15,7 @@ import { toObservable } from '@angular/core/rxjs-interop'; import { combineLatest, Observable, of as observableOf, Subject } from 'rxjs'; import { catchError, filter, map, takeUntil } from 'rxjs/operators'; -import { CustomFormFieldComponent, MatLabelComponent, CustomSelectComponent, CustomOptionComponent, StepOnNextResult } from '@stratosui/core'; +import { CustomFormFieldComponent, MatLabelComponent, CustomSelectComponent, CustomOptionComponent, StepOnNextResult, naturalCompare } from '@stratosui/core'; import { ServicesWallService } from '../../../features/services/services/services-wall.service'; import { StServiceOffering } from '../../../services/endpoint-data/stratos-types'; import { CfServiceCardComponent } from '../list/list-types/cf-services/cf-service-card/cf-service-card.component'; @@ -64,7 +64,7 @@ export class SelectServiceComponent implements OnDestroy, AfterContentInit { const source = this.offeringsSource(); if (!source) return []; const offerings: StServiceOffering[] = source.value() ?? []; - return [...offerings].sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? '')); + return [...offerings].sort((a, b) => naturalCompare(a?.name ?? '', b?.name ?? '')); }); // Bridge the signal to Observable for the template's `services$ | // async` binding (and downstream RxJS composition below). diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts index a6d07b75a1..a2798e83b1 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts @@ -18,6 +18,7 @@ import { combineLatest, Observable, of, Subscription } from 'rxjs'; import { delay, distinctUntilChanged, filter, map, startWith, withLatestFrom } from 'rxjs/operators'; import { CustomizationService } from '../../../core/customizations.types'; +import { naturalCompare } from '../../../shared/utils/natural-sort'; import { EndpointsService } from '../../../core/endpoints.service'; import { IHeaderBreadcrumbLink } from '../../../shared/components/page-header/page-header.types'; import { SidePanelMode, SidePanelService } from '../../../shared/services/side-panel.service'; @@ -229,7 +230,7 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit let navItems = this.collectNavigationRoutes('', this.router.config); // Sort by name - navItems = navItems.sort((a: SideNavItem, b: SideNavItem) => a.label.localeCompare(b.label)); + navItems = navItems.sort((a: SideNavItem, b: SideNavItem) => naturalCompare(a.label, b.label)); // Sort by position navItems = navItems.sort((a: SideNavItem, b: SideNavItem) => { diff --git a/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts b/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts index 9b4c84e1bf..25458b4216 100644 --- a/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/backup-restore/backup-endpoints/backup-endpoints.component.ts @@ -9,6 +9,7 @@ import { take, defaultIfEmpty, filter, map } from 'rxjs/operators'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { safeUnsubscribe } from '../../../../core/utils.service'; +import { naturalCompare } from '../../../../shared/utils/natural-sort'; import { ConfirmationDialogConfig } from '../../../../shared/components/confirmation-dialog.config'; import { ConfirmationDialogService } from '../../../../shared/components/confirmation-dialog.service'; import { ITableListDataSource } from '../../../../shared/components/list/data-sources-controllers/list-data-source-types'; @@ -124,7 +125,7 @@ export class BackupEndpointsComponent implements OnDestroy { // same data; loading state comes from `loading()`. const endpoints$ = toObservable(this.endpointsData.endpointsList, { injector: this.injector }).pipe( filter(entities => !!entities), - map(endpoints => [...endpoints].sort((a, b) => a.name.localeCompare(b.name))) + map(endpoints => [...endpoints].sort((a, b) => naturalCompare(a.name, b.name))) ); const fetching$ = toObservable(this.endpointsData.loading, { injector: this.injector }); diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts index fc323d9e32..0bbe9aa691 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts @@ -10,6 +10,7 @@ import { import { EndpointsSignalService } from '../../../core/signals/endpoints-signal.service'; import { ListStateStore } from '../../../shared/components/signal-list/list-state-store.service'; +import { naturalCompare } from '../../../shared/utils/natural-sort'; // ViewPipeline lives in the cloud-foundry package today (used by the // app/orgs/spaces/routes signal-list configs). Endpoints sits under @stratosui/core @@ -79,10 +80,8 @@ export class ViewPipeline { if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * sign; } - // Case-insensitive + numeric-aware string sort, mirroring the - // shared ViewPipeline in cloud-foundry/services/data-sources. if (typeof av === 'string' && typeof bv === 'string') { - return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * sign; + return naturalCompare(av, bv) * sign; } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-signal-config.service.ts b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-signal-config.service.ts index c811df7606..dd93c1631e 100644 --- a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-signal-config.service.ts +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-signal-config.service.ts @@ -1,6 +1,6 @@ import { Injectable, Injector, Signal, WritableSignal, computed, effect, inject, runInInjectionContext, signal } from '@angular/core'; -import { ListStateStore, SignalListSort } from '@stratosui/core'; +import { ListStateStore, SignalListSort, naturalCompare } from '@stratosui/core'; import { KubeHelmDataService } from '../../services/endpoint-data/kube-helm-data.service'; import { MonocularChart, StratosError } from '../../services/endpoint-data/kube-types'; @@ -156,7 +156,7 @@ export class MonocularChartsSignalConfigService { if (n) seen.add(n); } }); - return Array.from(seen).sort((a, b) => a.localeCompare(b)); + return Array.from(seen).sort((a, b) => naturalCompare(a, b)); }); readonly artifactHubRepos: Signal = computed(() => { @@ -167,7 +167,7 @@ export class MonocularChartsSignalConfigService { if (n) seen.add(n); } }); - return Array.from(seen).sort((a, b) => a.localeCompare(b)); + return Array.from(seen).sort((a, b) => naturalCompare(a, b)); }); async loadAll(): Promise { diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts index 3bed5f29d7..3ddfa4e49f 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts @@ -1,6 +1,7 @@ import { createComponent, EnvironmentInjector, Injector } from '@angular/core'; import { FormBuilder } from '@angular/forms'; +import { naturalCompare } from '@stratosui/core'; import { ConnectEndpointData } from '../../../../core/src/features/endpoints/connect.service'; import { RowState } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; import { EndpointAuthTypeConfig, IAuthForm } from '../../../../store/src/extension-types'; @@ -48,7 +49,7 @@ export class KubeConfigAuthHelper { } // Sort the subtypes - this.subTypes = this.subTypes.sort((a, b) => a.name.localeCompare(b.name)); + this.subTypes = this.subTypes.sort((a, b) => naturalCompare(a.name, b.name)); } } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts index cc01743ad1..fea6de6d7f 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts @@ -6,6 +6,7 @@ import { take, map, startWith } from 'rxjs/operators'; import { PageHeaderComponent } from '@stratosui/core'; import { LoadingPageComponent } from '@stratosui/core'; +import { naturalCompare } from '@stratosui/core'; import { StratosBaseCatalogEntity } from '../../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; import { UserFavoriteEndpoint } from '../../../../store/src/types/user-favorites.types'; import { UserFavoriteManager } from '../../../../store/src/user-favorite-manager'; @@ -93,7 +94,7 @@ export class KubernetesTabBaseComponent implements OnInit { } }); - tabsFromRouterConfig.sort((a: { label: string }, b: { label: string }) => a.label.localeCompare(b.label)); + tabsFromRouterConfig.sort((a: { label: string }, b: { label: string }) => naturalCompare(a.label, b.label)); return tabsFromRouterConfig; } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts index 8077af03ed..b86d41c435 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts @@ -1,6 +1,6 @@ import { AsyncPipe, TitleCasePipe } from '@angular/common'; import {Component, Input, inject, ChangeDetectionStrategy } from '@angular/core'; -import { CustomTooltipDirective } from '@stratosui/core'; +import { CustomTooltipDirective, naturalCompare } from '@stratosui/core'; import { isBefore, isAfter } from 'date-fns'; import { Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -84,7 +84,7 @@ export class KubernetesPodContainersComponent extends CardCell { ...containerStatus.map(c => this.createContainerForTable(c, row.spec.containers)), ...initContainerStatuses.map(c => this.createContainerForTable(c, row.spec.initContainers, true)) ]; - return containerStatusWithContainers.sort((a, b) => a.container.name.localeCompare(b.container.name)); + return containerStatusWithContainers.sort((a, b) => naturalCompare(a.container.name, b.container.name)); } private containerStatusToString(state: string, status: ContainerState): string { diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts index dbc14118d2..9787cdcf66 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts @@ -3,6 +3,8 @@ import { ActivatedRoute, RouterModule } from '@angular/router'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { naturalCompare } from '@stratosui/core'; + import { PageHeaderComponent } from '../../../../../../core/src/shared/components/page-header/page-header.component'; import { IPageSideNavTab } from '../../../../../../core/src/features/dashboard/page-side-nav/page-side-nav.component'; @@ -106,7 +108,7 @@ export class HelmReleaseTabBaseComponent implements OnDestroy { } }); - tabsFromRouterConfig.sort((a, b) => a.label.localeCompare(b.label)); + tabsFromRouterConfig.sort((a, b) => naturalCompare(a.label, b.label)); return tabsFromRouterConfig; } } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts index b67d18256f..e30577dd4d 100644 --- a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts @@ -3,7 +3,7 @@ import {Component, OnDestroy, signal, computed, inject, ChangeDetectionStrategy, import { RouterModule } from '@angular/router'; import { Store } from '@stratosui/store'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { ConfirmationDialogConfig, ConfirmationDialogService, EndpointsSignalService, SidePanelService } from '@stratosui/core'; +import { ConfirmationDialogConfig, ConfirmationDialogService, EndpointsSignalService, SidePanelService, naturalCompare } from '@stratosui/core'; import { AppState, RouterNav } from '@stratosui/store'; import { Observable, of } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; @@ -150,8 +150,8 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { distinctUntilChanged(), map((chartData: any) => ({ ...chartData, - containersChartData: chartData.containersChartData.sort((a: any, b: any) => a.name.localeCompare(b.name)), - podsChartData: chartData.podsChartData.sort((a: any, b: any) => a.name.localeCompare(b.name)) + containersChartData: chartData.containersChartData.sort((a: any, b: any) => naturalCompare(a.name, b.name)), + podsChartData: chartData.podsChartData.sort((a: any, b: any) => naturalCompare(a.name, b.name)) }) ) ); @@ -190,7 +190,7 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { }); } this.applyAnalysis(resources, this.analysisReport); - return Object.values(resources).sort((a: any, b: any) => a.kind.localeCompare(b.kind)); + return Object.values(resources).sort((a: any, b: any) => naturalCompare(a.kind, b.kind)); }); this.resources$ = toObservable(resourcesComputed); From e33cb099febc4fe16506a5441e7acd54a0402ed1 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 07:27:44 -0700 Subject: [PATCH 2/7] fix(list-state-store): reject empty sort.field when restoring from storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/signal-list/list-state-store.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts b/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts index 8e3a5ea152..2cc3e45461 100644 --- a/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts +++ b/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts @@ -149,5 +149,11 @@ function isSortTuple(v: unknown): v is ModeTuple { function isSort(v: unknown): v is SignalListSort { if (!v || typeof v !== 'object') return false; const s = v as Partial; - return typeof s.field === 'string' && (s.direction === 'asc' || s.direction === 'desc'); + // Empty field is rejected: a stored `{field:""}` was observed in older + // sessions and trips ViewPipeline into reading `row[""] = undefined`, + // collapsing every row to "equal" and emitting insertion order. Reject + // here so bind() falls back to the caller's named defaults. + return typeof s.field === 'string' + && s.field.length > 0 + && (s.direction === 'asc' || s.direction === 'desc'); } From e2dc77801af0cd9556893ccf834bb2b3682d8e23 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 07:59:33 -0700 Subject: [PATCH 3/7] feat(list): per-list match-case toggle for natural sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/data-sources/view-pipeline.ts | 9 ++++--- .../endpoints-signal-config.service.ts | 3 ++- .../signal-list/list-state-store.service.ts | 8 +++--- .../signal-list/signal-list.component.html | 15 +++++++++++ .../signal-list/signal-list.component.ts | 19 +++++++++++++- .../core/src/shared/utils/natural-sort.ts | 25 ++++++++++++++----- 6 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts index fc40092d38..77f1db9802 100644 --- a/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts +++ b/src/frontend/packages/cloud-foundry/src/services/data-sources/view-pipeline.ts @@ -8,6 +8,9 @@ export interface SortSpec { // without an explicit cast at the boundary. field: string; direction: 'asc' | 'desc'; + // Optional per-list match-case toggle. Default (undefined === false) is + // case-insensitive natural sort. + caseSensitive?: boolean; // Keep the generic so existing callers that parameterize SortSpec // continue to type-check. _phantom?: T; @@ -59,10 +62,10 @@ export class ViewPipeline { if (typeof av === 'number' && typeof bv === 'number') { return (av - bv) * sign; } - // Natural string sort: case-insensitive + numeric-aware. - // Single source of truth lives in @stratosui/core/naturalCompare. + // Natural string sort: numeric-aware. Per-list case-sensitivity + // override comes from spec.caseSensitive (default false). if (typeof av === 'string' && typeof bv === 'string') { - return naturalCompare(av, bv) * sign; + return naturalCompare(av, bv, spec.caseSensitive) * sign; } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts index 0bbe9aa691..4b6a6247f8 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-signal-config.service.ts @@ -21,6 +21,7 @@ import { naturalCompare } from '../../../shared/utils/natural-sort'; export interface SortSpec { field: string; direction: 'asc' | 'desc'; + caseSensitive?: boolean; _phantom?: T; } @@ -81,7 +82,7 @@ export class ViewPipeline { return (av - bv) * sign; } if (typeof av === 'string' && typeof bv === 'string') { - return naturalCompare(av, bv) * sign; + return naturalCompare(av, bv, spec.caseSensitive) * sign; } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); diff --git a/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts b/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts index 2cc3e45461..cae8509223 100644 --- a/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts +++ b/src/frontend/packages/core/src/shared/components/signal-list/list-state-store.service.ts @@ -153,7 +153,9 @@ function isSort(v: unknown): v is SignalListSort { // sessions and trips ViewPipeline into reading `row[""] = undefined`, // collapsing every row to "equal" and emitting insertion order. Reject // here so bind() falls back to the caller's named defaults. - return typeof s.field === 'string' - && s.field.length > 0 - && (s.direction === 'asc' || s.direction === 'desc'); + if (typeof s.field !== 'string' || s.field.length === 0) return false; + if (s.direction !== 'asc' && s.direction !== 'desc') return false; + // `caseSensitive` is optional; missing or boolean both pass. + if (s.caseSensitive !== undefined && typeof s.caseSensitive !== 'boolean') return false; + return true; } diff --git a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html index 4005f92c95..0d7403a073 100644 --- a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html +++ b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.html @@ -73,6 +73,21 @@ class="px-2 py-1 border border-content-border rounded bg-content-bg hover:bg-gray-100 dark:hover:bg-gray-700"> sort + } diff --git a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.ts b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.ts index 30b039c9b2..57f2501d26 100644 --- a/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.ts +++ b/src/frontend/packages/core/src/shared/components/signal-list/signal-list.component.ts @@ -174,6 +174,11 @@ export interface SignalListColumn { export interface SignalListSort { readonly field: string; readonly direction: 'asc' | 'desc'; + // Per-list case-sensitivity toggle. Default is undefined === false, i.e. + // case-insensitive natural sort. Flipping to true switches the underlying + // Intl.Collator from { sensitivity: 'base' } to { sensitivity: 'variant' }, + // so "Aa" sorts ahead of "aa". Persisted in the list-state store. + readonly caseSensitive?: boolean; } export interface SignalListDropdownOption { @@ -755,7 +760,7 @@ export class SignalListComponent implements AfterViewInit { onSortFieldChange(field: string): void { if (!this.config.sort) return; const current = this.config.sort(); - this.config.sort.set({ field, direction: current.direction }); + this.config.sort.set({ field, direction: current.direction, caseSensitive: current.caseSensitive }); this.config.pageIndex.set(0); } @@ -765,6 +770,18 @@ export class SignalListComponent implements AfterViewInit { this.config.sort.set({ field: current.field, direction: current.direction === 'asc' ? 'desc' : 'asc', + caseSensitive: current.caseSensitive, + }); + this.config.pageIndex.set(0); + } + + toggleSortCaseSensitive(): void { + if (!this.config.sort) return; + const current = this.config.sort(); + this.config.sort.set({ + field: current.field, + direction: current.direction, + caseSensitive: !current.caseSensitive, }); this.config.pageIndex.set(0); } diff --git a/src/frontend/packages/core/src/shared/utils/natural-sort.ts b/src/frontend/packages/core/src/shared/utils/natural-sort.ts index 8d1ab80224..bc03c18e54 100644 --- a/src/frontend/packages/core/src/shared/utils/natural-sort.ts +++ b/src/frontend/packages/core/src/shared/utils/natural-sort.ts @@ -1,17 +1,30 @@ /** - * Shared Intl.Collator for natural (human-friendly) string sorting. - * numeric: true -- "app-2" < "app-10" - * sensitivity: 'base' -- case-insensitive, accent-insensitive + * Shared Intl.Collator instances for natural (human-friendly) string sort. + * + * `numeric: true` makes "app-2" < "app-10" (digit runs compared by value). + * `sensitivity: 'base'` — case- AND accent-insensitive (default). + * `sensitivity: 'variant'` — case- AND accent-sensitive (the toggled mode). + * + * Two collators rather than one parameterized at call time so the runtime + * config stays inside the closed-over instance and the comparator stays + * tight (Array.sort fires its comparator N log N times). */ export const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', }); +export const naturalCollatorCaseSensitive = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'variant', +}); + /** * Drop-in replacement for localeCompare with natural sort semantics. - * Usage: array.sort((a, b) => naturalCompare(a.name, b.name)) + * Default policy: case-insensitive. Pass `caseSensitive=true` to opt into + * the per-list match-case toggle. */ -export function naturalCompare(a: string, b: string): number { - return naturalCollator.compare(a ?? '', b ?? ''); +export function naturalCompare(a: string, b: string, caseSensitive = false): number { + const collator = caseSensitive ? naturalCollatorCaseSensitive : naturalCollator; + return collator.compare(a ?? '', b ?? ''); } From 6da33269b40eb43def395a4fdeb319f382109996 Mon Sep 17 00:00:00 2001 From: Norman Abramovitz Date: Sat, 23 May 2026 07:59:43 -0700 Subject: [PATCH 4/7] fix(cf-summary): switch CLI button to stratos-icons terminal glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cf-summary-tab/cloud-foundry-summary-tab.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html index fe9cf424c7..5d19f0b0d7 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-summary-tab/cloud-foundry-summary-tab.component.html @@ -1,6 +1,6 @@ Date: Sat, 23 May 2026 07:59:57 -0700 Subject: [PATCH 5/7] refactor(metadata-item): render copy as inline glyph after the label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the copy button was rendered via 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. --- .../card-cf-info/card-cf-info.component.html | 2 +- .../metadata-item.component.html | 16 ++++++-- .../metadata-item.component.scss | 22 +++++++++-- .../metadata-item/metadata-item.component.ts | 38 ++++++++++++++++--- 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.html b/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.html index dccbb90be3..ce90e72323 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.html +++ b/src/frontend/packages/cloud-foundry/src/shared/components/cards/card-cf-info/card-cf-info.component.html @@ -6,7 +6,7 @@ {{ description() }}
-
{{ apiUrl() }}
+
{{ apiUrl() }}
diff --git a/src/frontend/packages/core/src/shared/components/metadata-item/metadata-item.component.html b/src/frontend/packages/core/src/shared/components/metadata-item/metadata-item.component.html index 110a83de3c..0baeaaf9ce 100644 --- a/src/frontend/packages/core/src/shared/components/metadata-item/metadata-item.component.html +++ b/src/frontend/packages/core/src/shared/components/metadata-item/metadata-item.component.html @@ -8,10 +8,20 @@