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/tabs/cf-services/cloud-foundry-services-signal.component.ts b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts index 6eb4ee46e0..050791e8d1 100644 --- a/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/cf/tabs/cf-services/cloud-foundry-services-signal.component.ts @@ -149,8 +149,11 @@ export class CloudFoundryServicesSignalComponent implements OnInit { { header: 'Name', key: 'name', sortField: 'name', kind: 'link', - link: (si: StServiceInstance) => - ['/services', si.type === 'user-provided' ? 'user-provided' : 'managed', si.cnsiGuid, si.guid], + link: (si: StServiceInstance) => { + const offeringGuid = si.servicePlan?.serviceOffering?.guid; + if (!offeringGuid) return null; + return ['/marketplace', si.cnsiGuid, offeringGuid, 'summary']; + }, render: (si: StServiceInstance) => si.name, widthHint: '14rem', }, 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 @@ 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/features/services/services-wall/services-wall.component.ts b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts index 9de25ae19e..87edae719c 100644 --- a/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/services/services-wall/services-wall.component.ts @@ -203,10 +203,14 @@ export class ServicesWallComponent implements OnInit { { header: 'Name', key: 'name', sortField: 'name', kind: 'link', - // Use the legacy detail-page route shape: /services/:type/:cnsi/:siGuid - // (the legacy detail page itself stays untouched in this migration). - link: (si: StServiceInstance) => - ['/services', si.type === 'user-provided' ? 'user-provided' : 'managed', si.cnsiGuid, si.guid], + // Land on the marketplace offering summary for managed instances; + // user-provided instances have no offering so the name stays + // plain text (signal-list renders `null` as non-link). + link: (si: StServiceInstance) => { + const offeringGuid = si.servicePlan?.serviceOffering?.guid; + if (!offeringGuid) return null; + return ['/marketplace', si.cnsiGuid, offeringGuid, 'summary']; + }, render: (si: StServiceInstance) => si.name, widthHint: '14rem', }, 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..1ba099b564 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,11 +1,16 @@ import { Signal, computed } from '@angular/core'; +import { detectSortContext, 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 // 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; @@ -45,7 +50,18 @@ export class ViewPipeline { const getValue: (row: T) => unknown = extractor ? extractor : (row: T) => (row as Record)[spec.field]; - return [...this.filteredItems()].sort((a, b) => { + const items = [...this.filteredItems()]; + // Collection-aware pre-pass: scan all extracted string values for + // the xxx-sep?-nnn pattern. When most entries match, naturalCompare + // strips separator chars before tokenizing so `Org 4`, `Org_5`, and + // `Org6` sequence by their numeric token. + const strings: string[] = []; + for (const it of items) { + const v = getValue(it); + if (typeof v === 'string') strings.push(v); + } + const ctx = strings.length > 0 ? detectSortContext(strings) : {}; + return items.sort((a, b) => { const av = getValue(a); const bv = getValue(b); if (av == null && bv == null) return 0; @@ -57,14 +73,8 @@ 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). if (typeof av === 'string' && typeof bv === 'string') { - return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) * sign; + return naturalCompare(av, bv, spec.caseSensitive, spec.direction, ctx); } 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/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/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..94ad37995f 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 { detectSortContext, 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 @@ -20,6 +21,7 @@ import { ListStateStore } from '../../../shared/components/signal-list/list-stat export interface SortSpec { field: string; direction: 'asc' | 'desc'; + caseSensitive?: boolean; _phantom?: T; } @@ -70,7 +72,15 @@ export class ViewPipeline { const getValue: (row: T) => unknown = extractor ? extractor : (row: T) => (row as Record)[spec.field]; - return [...this.filteredItems()].sort((a, b) => { + const items = [...this.filteredItems()]; + // Collection-aware pre-pass — see naturalCompare/detectSortContext. + const strings: string[] = []; + for (const it of items) { + const v = getValue(it); + if (typeof v === 'string') strings.push(v); + } + const ctx = strings.length > 0 ? detectSortContext(strings) : {}; + return items.sort((a, b) => { const av = getValue(a); const bv = getValue(b); if (av == null && bv == null) return 0; @@ -79,10 +89,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, spec.caseSensitive, spec.direction, ctx); } return av < bv ? -1 * sign : av > bv ? 1 * sign : 0; }); diff --git a/src/frontend/packages/core/src/public-api.ts b/src/frontend/packages/core/src/public-api.ts index 609f4ebf94..7f0cb5ed00 100644 --- a/src/frontend/packages/core/src/public-api.ts +++ b/src/frontend/packages/core/src/public-api.ts @@ -9,7 +9,13 @@ export * from './core/extension/extension-service'; // Utils export { getIdFromRoute, pathGet, safeStringToObj, urlValidationExpression, truthyIncludingZeroString } from './core/utils.service'; -export { naturalCollator, naturalCompare } from './shared/utils/natural-sort'; +export { + naturalCollator, + naturalCompare, + detectSortContext, + NO_SEPARATOR_PATTERN_THRESHOLD, +} from './shared/utils/natural-sort'; +export type { NaturalSortContext } from './shared/utils/natural-sort'; export { GlobalEventService, GlobalEventData, endpointEventKey } from './shared/global-events.service'; export type { IGlobalEvent, IGlobalEventConfig, GlobalEventTypes } from './shared/global-events.service'; export { environment } from './environments/environment'; 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 @@ } 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.spec.ts b/src/frontend/packages/core/src/shared/utils/natural-sort.spec.ts index 52d37b7028..c69326d598 100644 --- a/src/frontend/packages/core/src/shared/utils/natural-sort.spec.ts +++ b/src/frontend/packages/core/src/shared/utils/natural-sort.spec.ts @@ -1,36 +1,41 @@ import { describe, it, expect } from 'vitest'; -import { naturalCompare, naturalCollator } from './natural-sort'; +import { + naturalCompare, + naturalCollator, + detectSortContext, + NO_SEPARATOR_PATTERN_THRESHOLD, +} from './natural-sort'; describe('naturalCompare', () => { it('sorts numeric segments naturally', () => { const input = ['app-1', 'app-10', 'app-2', 'app-20', 'app-3']; - const sorted = [...input].sort(naturalCompare); + const sorted = [...input].sort((a, b) => naturalCompare(a, b)); expect(sorted).toEqual(['app-1', 'app-2', 'app-3', 'app-10', 'app-20']); }); it('handles multiple numeric groups', () => { const input = ['v1.10.1', 'v1.2.10', 'v1.2.3', 'v2.1.0']; - const sorted = [...input].sort(naturalCompare); + const sorted = [...input].sort((a, b) => naturalCompare(a, b)); expect(sorted).toEqual(['v1.2.3', 'v1.2.10', 'v1.10.1', 'v2.1.0']); }); - it('is case-insensitive', () => { + it('is case-insensitive by default', () => { expect(naturalCompare('App-2', 'app-2')).toBe(0); const input = ['App-10', 'app-1', 'APP-2']; - const sorted = [...input].sort(naturalCompare); + const sorted = [...input].sort((a, b) => naturalCompare(a, b)); expect(sorted).toEqual(['app-1', 'APP-2', 'App-10']); }); it('sorts pure alpha strings alphabetically', () => { const input = ['cherry', 'apple', 'banana']; - const sorted = [...input].sort(naturalCompare); + const sorted = [...input].sort((a, b) => naturalCompare(a, b)); expect(sorted).toEqual(['apple', 'banana', 'cherry']); }); it('sorts numeric-only strings numerically', () => { const input = ['3', '1', '20', '10']; - const sorted = [...input].sort(naturalCompare); + const sorted = [...input].sort((a, b) => naturalCompare(a, b)); expect(sorted).toEqual(['1', '3', '10', '20']); }); @@ -44,10 +49,152 @@ describe('naturalCompare', () => { expect(naturalCompare('', '')).toBe(0); expect(naturalCompare('', 'a')).toBeLessThan(0); }); + + describe('missing-token decision table', () => { + // The interesting case: a bare-prefix value (no number at a token + // position) compared against a sibling that does carry a number. + // + // MatchCase ON MatchCase OFF + // ASC smallest (-∞) empty (lex) + // DESC largest (+∞) empty (lex) + // + // With MatchCase ON the bare-prefix is PINNED to the head of its + // numeric siblings regardless of direction. With OFF it just rides + // lex continuation. + + const input = ['org_2', 'org_', 'org_1', 'org_10']; + + it('MatchCase OFF + ASC → bare prefix first (lex: shorter wins ASC)', () => { + const sorted = [...input].sort((a, b) => naturalCompare(a, b, false, 'asc')); + expect(sorted).toEqual(['org_', 'org_1', 'org_2', 'org_10']); + }); + + it('MatchCase OFF + DESC → bare prefix last (lex: shorter loses DESC)', () => { + const sorted = [...input].sort((a, b) => naturalCompare(a, b, false, 'desc')); + expect(sorted).toEqual(['org_10', 'org_2', 'org_1', 'org_']); + }); + + it('MatchCase ON + ASC → bare prefix at head (smallest = -∞)', () => { + const sorted = [...input].sort((a, b) => naturalCompare(a, b, true, 'asc')); + expect(sorted).toEqual(['org_', 'org_1', 'org_2', 'org_10']); + }); + + it('MatchCase ON + DESC → bare prefix still at head (largest = +∞)', () => { + const sorted = [...input].sort((a, b) => naturalCompare(a, b, true, 'desc')); + expect(sorted).toEqual(['org_', 'org_10', 'org_2', 'org_1']); + }); + }); + + describe('case-sensitive mode (locale-aware)', () => { + it('reorders strings that differ only in case', () => { + const off = naturalCompare('Apple', 'apple', false); + const on = naturalCompare('Apple', 'apple', true); + expect(off).toBe(0); + expect(on).not.toBe(0); + }); + + it('still equates strings that match exactly under match-case', () => { + expect(naturalCompare('Apple', 'Apple', true)).toBe(0); + expect(naturalCompare('apple', 'apple', true)).toBe(0); + }); + }); + + describe('direction handling', () => { + it('reverses the result when direction is desc', () => { + const asc = naturalCompare('a', 'b', false, 'asc'); + const desc = naturalCompare('a', 'b', false, 'desc'); + expect(asc).toBeLessThan(0); + expect(desc).toBeGreaterThan(0); + }); + + it('sorts numbers descending when direction is desc', () => { + const sorted = ['org_1', 'org_3', 'org_2', 'org_10'] + .sort((a, b) => naturalCompare(a, b, false, 'desc')); + expect(sorted).toEqual(['org_10', 'org_3', 'org_2', 'org_1']); + }); + }); + + describe('cross-type tokens', () => { + it('treats numeric-led values as sorting before letter-led values', () => { + const sorted = ['org', '1org', 'a-org'].sort((a, b) => naturalCompare(a, b)); + expect(sorted[0]).toBe('1org'); + }); + }); + + describe('stripSeparators ctx (collection-aware)', () => { + // The user-asked case: Org 3, Org_4, Org5 should sequence together + // by their numeric token even though they use different (or no) + // separator characters. With stripSeparators on, separators are + // stripped before tokenization so all three collapse to [Org, N]. + + it('sequences Org 3 / Org_4 / Org5 by their number when stripSeparators is on', () => { + const ctx = { stripSeparators: true }; + const sorted = ['Org5', 'Org 3', 'Org_4'].sort( + (a, b) => naturalCompare(a, b, false, 'asc', ctx), + ); + expect(sorted).toEqual(['Org 3', 'Org_4', 'Org5']); + }); + + it('keeps the original separator-divergent order when stripSeparators is off', () => { + // Without stripping, the trailing separator chars become part of + // each text token, so the strings diverge before the numeric + // tokens are reached. Locale base sensitivity puts '_' before + // digits before spaces — exact ordering is implementation- + // dependent, but the entries no longer sequence purely by number. + const sorted = ['Org5', 'Org 3', 'Org_4'].sort( + (a, b) => naturalCompare(a, b, false, 'asc'), + ); + expect(sorted).not.toEqual(['Org 3', 'Org_4', 'Org5']); + }); + }); }); -describe('naturalCollator', () => { +describe('detectSortContext', () => { + it('flips stripSeparators on when most values are xxx-sep?-nnn', () => { + const values = ['org_1', 'org_2', 'org_10', 'org 5', 'org7']; + const ctx = detectSortContext(values); + expect(ctx.stripSeparators).toBe(true); + }); + + it('keeps stripSeparators off when only outliers carry digits', () => { + const values = ['apple', 'banana', 'cherry', 'date', 'elderberry', 'Org4']; + const ctx = detectSortContext(values); + expect(ctx.stripSeparators).toBe(false); + }); + + it('treats empty / null entries as not contributing to the count', () => { + const values = ['', '', 'org_1', 'org_2']; + const ctx = detectSortContext(values); + expect(ctx.stripSeparators).toBe(true); + }); + + it('returns stripSeparators undefined / false for empty input', () => { + const ctx = detectSortContext([]); + expect(ctx.stripSeparators).toBeFalsy(); + }); +}); + +describe('end-to-end: collection-aware sort flows', () => { + it('sequences Org 3, Org_4, Org5 in a mixed-separator collection', () => { + const values = ['Org 3', 'Org_4', 'Org5', 'Org_2', 'Org 1']; + const ctx = detectSortContext(values); + expect(ctx.stripSeparators).toBe(true); + const sorted = [...values].sort( + (a, b) => naturalCompare(a, b, false, 'asc', ctx), + ); + expect(sorted).toEqual(['Org 1', 'Org_2', 'Org 3', 'Org_4', 'Org5']); + }); +}); + +describe('naturalCollator (legacy compat export)', () => { it('is an Intl.Collator instance', () => { expect(naturalCollator).toBeInstanceOf(Intl.Collator); }); }); + +describe('NO_SEPARATOR_PATTERN_THRESHOLD', () => { + it('is a fraction in [0, 1]', () => { + expect(NO_SEPARATOR_PATTERN_THRESHOLD).toBeGreaterThanOrEqual(0); + expect(NO_SEPARATOR_PATTERN_THRESHOLD).toBeLessThanOrEqual(1); + }); +}); 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..b07783f4bc 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,173 @@ /** - * Shared Intl.Collator for natural (human-friendly) string sorting. - * numeric: true -- "app-2" < "app-10" - * sensitivity: 'base' -- case-insensitive, accent-insensitive + * Natural string comparison with first-class numeric awareness. + * + * Tokenizes each input on runs of digits — `app-23-prod` becomes the + * sequence [text:"app-", num:23, text:"-prod"] — then streams the two + * token sequences in lock-step. Numeric tokens compare numerically + * (so "org_2" < "org_10"), text tokens compare via an Intl.Collator + * with locale-aware sensitivity. + * + * Behaviour parameters: + * caseSensitive false (default) — sensitivity: 'base' (a ≡ A ≡ ä) + * true — sensitivity: 'variant' (locale-aware + * case- and accent-distinguishing) + * direction 'asc' (default) or 'desc'. Baked into the return + * value — DO NOT multiply the result by your own + * direction sign. The direction matters for the + * missing-token decision below. + * ctx optional collection-aware tokenizer hint, produced + * by detectSortContext over the array being sorted. + * + * Missing-token decision table (when one side runs out of tokens or + * has nothing where the other has a number): + * + * MatchCase ON MatchCase OFF + * ASC smallest (-∞) empty (lex continuation) + * DESC largest (+∞) empty (lex continuation) + * + * Effect: with MatchCase ON the bare-prefix entity is *pinned* to the + * head of its numeric siblings regardless of direction (smallest in + * ASC, largest in DESC — both put it first visually). With MatchCase + * OFF we just do lex-continuation: shorter wins in ASC, loses in DESC. + * + * --- Collection-aware tokenization (stripSeparators) ----------------- + * + * The pairwise comparator can't see the rest of the collection, so the + * heuristic that decides whether `Org 4`, `Org_5`, and `Org6` ought to + * sequence together by their numeric token (vs being held apart by + * their separator characters) lives in detectSortContext below. The + * output is fed into naturalCompare via the `ctx` parameter: + * + * - ctx.stripSeparators === true: whitespace/_/- are stripped from + * each value before tokenizing, collapsing every input to the + * alternating xxx-nnn form. Lossy: `Org` and `Org_` compare equal. + * - ctx.stripSeparators omitted/false: tokenize as-is — separators + * remain part of the surrounding text tokens (legacy behaviour). + * + * The flag is set when at least NO_SEPARATOR_PATTERN_THRESHOLD of the + * non-empty values exhibit a "letter, optional separator chars, digit" + * (or mirror) form. The threshold treats the strip as opt-in for + * collections that are mostly in the xxx-nnn family — when only a few + * outliers carry digits, we leave them as opaque text instead. + */ + +const baseCollator = new Intl.Collator(undefined, { sensitivity: 'base' }); +const variantCollator = new Intl.Collator(undefined, { sensitivity: 'variant' }); + +/** + * Fraction of values that must exhibit the `letter–separator?–digit` + * pattern before detectSortContext flips stripSeparators on. Set at + * 0.3 deliberately on the low end — once roughly a third of a list + * looks like the xxx-nnn family, the pattern is meaningful enough that + * grouping outliers with it reads as the right answer. Tune up if it + * becomes too aggressive in practice. + */ +export const NO_SEPARATOR_PATTERN_THRESHOLD = 0.3; + +/** + * Back-compat export: the original case-insensitive numeric-aware + * Intl.Collator instance. `local-filtering-sorting.ts` still imports + * it directly. New code should prefer `naturalCompare`. */ export const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base', }); +export interface NaturalSortContext { + /** + * Strip whitespace, underscore, and hyphen from each value before + * tokenizing. Causes `Org_5`, `Org 5`, `Org-5`, and `Org5` to all + * tokenize as [text:"Org", num:5] and sequence together by number. + * Set by detectSortContext when the collection is predominantly in + * the xxx-separator?-nnn family. + */ + stripSeparators?: boolean; +} + +interface TextToken { text: string; num?: undefined; } +interface NumToken { text?: undefined; num: number; } +type Token = TextToken | NumToken; + +const SEPARATOR_CHAR_CLASS = /[\s_\-]/g; +// Pattern that flags an entry as part of the xxx-sep?-nnn family. Both +// directions covered (letter→digit and digit→letter), separator class +// matches zero or more of [whitespace _ -]. +const XXX_NNN_FAMILY = /[A-Za-z][\s_\-]*\d|\d[\s_\-]*[A-Za-z]/; + /** - * Drop-in replacement for localeCompare with natural sort semantics. - * Usage: array.sort((a, b) => naturalCompare(a.name, b.name)) + * Analyze a collection of strings to decide which tokenization hints + * to apply when sorting them. The result is passed via `ctx` into + * subsequent naturalCompare calls. */ -export function naturalCompare(a: string, b: string): number { - return naturalCollator.compare(a ?? '', b ?? ''); +export function detectSortContext(values: readonly string[]): NaturalSortContext { + let totalNonEmpty = 0; + let matched = 0; + for (const v of values) { + if (!v) continue; + totalNonEmpty++; + if (XXX_NNN_FAMILY.test(v)) matched++; + } + return { + stripSeparators: totalNonEmpty > 0 + && matched / totalNonEmpty >= NO_SEPARATOR_PATTERN_THRESHOLD, + }; +} + +function tokenize(value: string, ctx: NaturalSortContext): Token[] { + if (!value) return []; + const cleaned = ctx.stripSeparators ? value.replace(SEPARATOR_CHAR_CLASS, '') : value; + if (!cleaned) return []; + const tokens: Token[] = []; + const re = /(\d+)/g; + let lastIdx = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(cleaned)) !== null) { + if (match.index > lastIdx) { + tokens.push({ text: cleaned.slice(lastIdx, match.index) }); + } + tokens.push({ num: Number(match[1]) }); + lastIdx = match.index + match[1].length; + } + if (lastIdx < cleaned.length) tokens.push({ text: cleaned.slice(lastIdx) }); + return tokens; +} + +export function naturalCompare( + a: string, + b: string, + caseSensitive = false, + direction: 'asc' | 'desc' = 'asc', + ctx: NaturalSortContext = {}, +): number { + const sign = direction === 'asc' ? 1 : -1; + const ta = tokenize(a ?? '', ctx); + const tb = tokenize(b ?? '', ctx); + const collator = caseSensitive ? variantCollator : baseCollator; + const len = Math.max(ta.length, tb.length); + + for (let i = 0; i < len; i++) { + const tokA = ta[i] as Token | undefined; + const tokB = tb[i] as Token | undefined; + + if (!tokA && !tokB) return 0; + if (!tokA) return caseSensitive ? -1 : -1 * sign; + if (!tokB) return caseSensitive ? 1 : 1 * sign; + + if (tokA.num !== undefined && tokB.num !== undefined) { + const d = tokA.num - tokB.num; + if (d !== 0) return d * sign; + continue; + } + if (tokA.text !== undefined && tokB.text !== undefined) { + const d = collator.compare(tokA.text, tokB.text); + if (d !== 0) return d * sign; + continue; + } + // Mixed types — numeric tokens sort before text tokens (digits + // before letters under nearly all collations; matches the lex + // intuition that "3" < "abc"). + return (tokA.num !== undefined ? -1 : 1) * sign; + } + return 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);