diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx index 2eeb70354..494b6fee9 100644 --- a/src/features/clusters/upsert/ClusterBilling.tsx +++ b/src/features/clusters/upsert/ClusterBilling.tsx @@ -1,7 +1,9 @@ import { Button } from '@/components/ui/button'; +import { ResourcesPerInstance } from '@/features/clusters/upsert/components/ResourcesPerInstance'; import { PaymentMethodsDisplay } from '@/features/organization/billing/paymentMethod/PaymentMethodsDisplay'; import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; import { PaymentMethodStatus } from '@/integrations/stripe/paymentMethodStatus'; +import { SchemaPlan } from '@/lib/api.gen'; import { useQuery } from '@tanstack/react-query'; import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react'; import { useState } from 'react'; @@ -13,6 +15,8 @@ interface ClusterBillingProps { readonly onSaveStateForBillingRedirect: (redirecting: boolean) => void; readonly onSubmit?: () => void; readonly organizationId: string; + readonly selectedAutoRenew: boolean; + readonly selectedPlan: SchemaPlan | undefined; } export function ClusterBilling({ @@ -22,6 +26,8 @@ export function ClusterBilling({ onSaveStateForBillingRedirect, onSubmit, organizationId, + selectedAutoRenew, + selectedPlan, }: ClusterBillingProps) { const { data: organization } = useQuery(getOrganizationQueryOptions(organizationId)); const billing = organization?.billing; @@ -54,7 +60,7 @@ export function ClusterBilling({ this page.
  • Your account representative can work with you to sort out more precise details, and to help - accomplish your objectives with this new + accomplish your objectives with this cluster. Contact us, we are here to help.
  • @@ -77,14 +83,32 @@ export function ClusterBilling({ return (<> + {selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && ( + + )} +

    Payment method:

    diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index a667eccbe..3727fb294 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -15,7 +15,7 @@ import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSch import { SchemaPlan, SchemaRegion } from '@/lib/api.gen'; import { ArrowRight } from 'lucide-react'; import { Suspense, useEffect, useMemo } from 'react'; -import { UseFormReturn } from 'react-hook-form'; +import { UseFormReturn, useFormState } from 'react-hook-form'; import { z } from 'zod'; interface ClusterDetailsProps { @@ -45,14 +45,11 @@ export function ClusterDetails({ selectedPlan, totalPrice, }: ClusterDetailsProps) { + const { isDirty, isValid } = useFormState(); const availablePerformanceDescriptions = useMemo(() => Object.keys(deploymentToPerformanceToPlan[selectedDeployment] || {}), [deploymentToPerformanceToPlan, selectedDeployment]); const availableDeploymentTypes = useMemo(() => - Object.keys(deploymentToPerformanceToPlan).sort((a, b) => { - const aPrice = Object.values(deploymentToPerformanceToPlan[a])[0].priceUsd ?? 0; - const bPrice = Object.values(deploymentToPerformanceToPlan[b])[0].priceUsd ?? 0; - return aPrice - bPrice; - }), [deploymentToPerformanceToPlan]); + Object.keys(deploymentToPerformanceToPlan).sort(), [deploymentToPerformanceToPlan]); useEffect(function autoSelectFirstAvailablePerformanceDescription() { if (availablePerformanceDescriptions?.length && !availablePerformanceDescriptions.includes(selectedPerformance)) { @@ -61,7 +58,7 @@ export function ClusterDetails({ } }, [selectedDeployment, selectedPerformance, availablePerformanceDescriptions, form]); - const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = selectedDeployment === 'Self-Hosted'; return (<>
    @@ -69,7 +66,7 @@ export function ClusterDetails({ control={form.control} name="systemName" render={({ field }) => ( - + Harper System Name + ( + + Auto Renew + + + + + + )} + /> + ( - Performance & Usage + {selectedDeployment.startsWith('Self') ? 'Support' : 'Performance'} & Usage }> @@ -225,12 +242,12 @@ export function ClusterDetails({ />) } - {selectedPlan?.resourcesPerInstance && ( - + {selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && ( + )}
    - diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index 994ae8f0c..4461557d2 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -63,7 +63,7 @@ export function ClusterForm({ const refineZod = useCallback((data: z.infer, ctx: z.RefinementCtx) => { const names = new Set(); const selectedPlan = deploymentToPerformanceToPlan?.[data.deploymentDescription]?.[data.performanceDescription]; - const isSelfManaged = data.deploymentDescription === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = data.deploymentDescription === 'Self-Hosted'; if (isSelfManaged) { for (let i = 0; i < data.instances.length; i++) { const fqdn = calculateInstanceFQDN(data.instances[i]); @@ -130,6 +130,7 @@ export function ClusterForm({ const selectedPerformance = form.watch('performanceDescription'); const selectedRegionPlans = form.watch('regionPlans'); const selectedInstances = form.watch('instances'); + const selectedAutoRenew = form.watch('autoRenew'); useEffect(() => { setLimitRegionParameters({ @@ -140,7 +141,7 @@ export function ClusterForm({ useEffect(function syncInstancesAndRegionsWithSelfManagedSelection() { const values = form.getValues(); - const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = selectedDeployment === 'Self-Hosted'; if (!selectedDeployment) { return; } @@ -204,7 +205,7 @@ export function ClusterForm({ }, [selectedPlan, selectedRegionPlans, form, regionNameToLatencyToRegion, regionLocations]); useEffect(function syncRegionSelectionsWithPossibleRegions() { - const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = selectedDeployment === 'Self-Hosted'; if (!isSelfManaged && Object.keys(regionNameToLatencyToRegion).length && selectedRegionPlans.length) { for (let i = 0; i < selectedRegionPlans.length; i++) { const regionPlan = selectedRegionPlans[i]; @@ -217,7 +218,7 @@ export function ClusterForm({ const totalPrice = !selectedPlan?.priceUsd ? 0 - : selectedDeployment === 'Manage Your Own Installation/Configuration' + : selectedDeployment === 'Self-Hosted' ? selectedInstances.length * selectedPlan.priceUsd : selectedRegionPlans.reduce((total, region) => { const regionPlan = regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]; @@ -243,24 +244,24 @@ export function ClusterForm({ const plans: SchemaRegionPlan[] = []; const plan = deploymentToPerformanceToPlan[formData.deploymentDescription][formData.performanceDescription]; - const isSelfManaged = formData.deploymentDescription === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = formData.deploymentDescription === 'Self-Hosted'; if (isSelfManaged) { for (const instance of formData.instances) { plans.push({ - planId: plan.id, - autoRenew: true, - operationsApiSecure: instance.secure === 'true', + autoRenew: formData.autoRenew, instanceFqdn: instance.fqdn, operationsApiPort: instance.port || defaultOperationsApiPort, + operationsApiSecure: instance.secure === 'true', + planId: plan.id, }); } } else { for (const regionPlan of formData.regionPlans) { const region = regionNameToLatencyToRegion[regionPlan.regionName][regionPlan.latencyDescription]; plans.push({ + autoRenew: formData.autoRenew, planId: plan.id, regionId: region.id, - autoRenew: true, }); } } @@ -276,7 +277,7 @@ export function ClusterForm({ name: formData.systemName, abbreviatedName: isSelfManaged ? undefined : (formData.abbreviatedName || calculatedNames.suggestedAbbreviatedName), fqdn: isSelfManaged && formData.fqdn || undefined, - autoRenew: true, + autoRenew: formData.autoRenew, regionPlans: plans, }, { onSuccess: onClusterCreatedCallback }); } @@ -334,12 +335,14 @@ export function ClusterForm({ details:

    ) } diff --git a/src/features/clusters/upsert/ClusterRegions.tsx b/src/features/clusters/upsert/ClusterRegions.tsx index a84fdfbe5..4406420c2 100644 --- a/src/features/clusters/upsert/ClusterRegions.tsx +++ b/src/features/clusters/upsert/ClusterRegions.tsx @@ -3,7 +3,7 @@ import { RegionFormInputs } from '@/features/clusters/upsert/components/RegionFo import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema'; import { SchemaPlan, SchemaRegion } from '@/lib/api.gen'; import { PlusIcon } from 'lucide-react'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useFieldArray, UseFormReturn } from 'react-hook-form'; import { z } from 'zod'; @@ -27,17 +27,20 @@ export function ClusterRegions({ name: 'regionPlans', }); - const onAddARegionClick = useCallback(() => { + const nextAvailableRegionToAdd = useMemo(() => { const selectedRegionNames = selectedRegionPlans.map(region => regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]?.region); - const firstNewRegionLocation = regionLocations?.find(r => !selectedRegionNames.includes(r.region)); - if (firstNewRegionLocation) { + return regionLocations?.find(r => !selectedRegionNames.includes(r.region)); + }, [regionLocations, regionNameToLatencyToRegion, selectedRegionPlans]); + + const onAddARegionClick = useCallback(() => { + if (nextAvailableRegionToAdd) { regionPlansFieldArray.append({ - regionName: firstNewRegionLocation.region, - latencyDescription: firstNewRegionLocation.latencyDescription, + regionName: nextAvailableRegionToAdd.region, + latencyDescription: nextAvailableRegionToAdd.latencyDescription, }); void form.trigger(); } - }, [regionPlansFieldArray, form, regionLocations, regionNameToLatencyToRegion, selectedRegionPlans]); + }, [form, nextAvailableRegionToAdd, regionPlansFieldArray]); return (<> {regionPlansFieldArray.fields.map((field, index) => ( @@ -52,7 +55,7 @@ export function ClusterRegions({ /> ))} -
    + {nextAvailableRegionToAdd && (
    -
    +
    )} ); } diff --git a/src/features/clusters/upsert/components/RegionFormInputs.tsx b/src/features/clusters/upsert/components/RegionFormInputs.tsx index 09ea025c0..1cf301970 100644 --- a/src/features/clusters/upsert/components/RegionFormInputs.tsx +++ b/src/features/clusters/upsert/components/RegionFormInputs.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/select'; import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema'; import { SchemaPlan, SchemaRegion } from '@/lib/api.gen'; +import { sortByNumberPrefix } from '@/lib/arrays/sort/byNumberPrefix'; import { TrashIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo } from 'react'; import { Control, UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'; @@ -36,25 +37,25 @@ export function RegionFormInputs({ regionNameToLatencyToRegion, }: RegionFormInputsProps) { const availableRegionNames = useMemo(() => - Object.keys(regionNameToLatencyToRegion), [regionNameToLatencyToRegion]); + Object.keys(regionNameToLatencyToRegion).sort(), [regionNameToLatencyToRegion]); const isDedicated = form.watch('deploymentDescription')?.startsWith('Dedicated'); const selectedRegionName = form.watch(`regionPlans.${index}.regionName`); const selectedLatencyDescription = form.watch(`regionPlans.${index}.latencyDescription`); const availableLatencyDescriptions = useMemo(() => - Object.keys(regionNameToLatencyToRegion[selectedRegionName] || {}), [regionNameToLatencyToRegion, selectedRegionName]); + Object.keys(regionNameToLatencyToRegion[selectedRegionName] || {}).sort(sortByNumberPrefix).reverse(), [regionNameToLatencyToRegion, selectedRegionName]); useEffect(function autoPickLatencyDescription() { if (selectedRegionName && availableLatencyDescriptions?.length && !availableLatencyDescriptions?.includes(selectedLatencyDescription)) { const oldValue = selectedLatencyDescription?.split(' ')[0].toLowerCase(); const newValue = availableLatencyDescriptions.find(description => !oldValue ? true : description.split(' ')[0].toLowerCase() === oldValue) || availableLatencyDescriptions[0]; form.setValue(`regionPlans.${index}.latencyDescription`, newValue); - form.trigger(); + void form.trigger(); } }, [availableLatencyDescriptions, form, index, selectedLatencyDescription, selectedRegionName]); const onRemoveClicked = useCallback(() => { fieldArray?.remove(index); - form.trigger(); + void form.trigger(); }, [fieldArray, form, index]); return ( diff --git a/src/features/clusters/upsert/components/ResourcesPerInstance.tsx b/src/features/clusters/upsert/components/ResourcesPerInstance.tsx index ed21d2d06..e83c2a033 100644 --- a/src/features/clusters/upsert/components/ResourcesPerInstance.tsx +++ b/src/features/clusters/upsert/components/ResourcesPerInstance.tsx @@ -3,7 +3,7 @@ import { FormControl } from '@/components/ui/form/FormControl'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { SchemaResourcesPerInstance } from '@/lib/api.gen'; +import { SchemaPlanLimits, SchemaResourcesPerInstance } from '@/lib/api.gen'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { cn } from '@/lib/cn'; import { humanFileSize } from '@/lib/humanFileSize'; @@ -11,7 +11,8 @@ import { humanNumber } from '@/lib/humanNumber'; import { ArrowDownIcon, ArrowRightIcon } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; -export function ResourcesPerInstance({ resourcesPerInstance }: { +export function ResourcesPerInstance({ planLimits, resourcesPerInstance }: { + readonly planLimits: SchemaPlanLimits, readonly resourcesPerInstance: SchemaResourcesPerInstance }) { const [toggled, setToggled] = useState(false); @@ -20,33 +21,73 @@ export function ResourcesPerInstance({ resourcesPerInstance }: { }, [toggled, setToggled]); const rows = useMemo(() => [ - resourcesPerInstance.readIopsLimit && { - label: 'Reads', - value: `${humanNumber(resourcesPerInstance.readIopsLimit * 60)}/min`, + planLimits.totalReadCount && { + label: 'Total Reads', + value: `${humanNumber(planLimits.totalReadCount)} reads`, }, - resourcesPerInstance.writeIopsLimit && { - label: 'Writes', - value: `${humanNumber(resourcesPerInstance.writeIopsLimit * 60)}/min`, + planLimits.totalReadsBytes && { + label: 'Total Read Transfer', + value: `${humanFileSize(planLimits.totalReadsBytes)}`, }, - resourcesPerInstance.cpuCores && { - label: 'CPU Cores', - value: `${humanNumber(resourcesPerInstance.cpuCores)}`, + planLimits.readsPerMinuteCount && { + label: 'Read Rate', + value: `${humanNumber(planLimits.readsPerMinuteCount * 60)}/min`, }, - resourcesPerInstance.threads && { - label: 'Threads', - value: `${humanNumber(resourcesPerInstance.threads)}`, + planLimits.readsPerMinuteBytes && { + label: 'Read Bandwidth', + value: `${humanFileSize(planLimits.readsPerMinuteBytes * 60)}/min`, }, - resourcesPerInstance.memoryMb && { - label: 'Memory', - value: humanFileSize(resourcesPerInstance.memoryMb, 1024 * 1024), + planLimits.totalWriteCount && { + label: 'Total Writes', + value: `${humanNumber(planLimits.totalWriteCount)} reads`, }, - resourcesPerInstance.storageGb && { + planLimits.totalWritesBytes && { + label: 'Total Write Transfer', + value: `${humanFileSize(planLimits.totalWritesBytes)}`, + }, + planLimits.writesPerMinuteCount && { + label: 'Write Rate', + value: `${humanNumber(planLimits.writesPerMinuteCount * 60)}/min`, + }, + planLimits.writesPerMinuteBytes && { + label: 'Write Bandwidth', + value: `${humanFileSize(planLimits.writesPerMinuteBytes * 60)}/min`, + }, + planLimits.totalRealTimeMessageDeliveries && { + label: 'Total Real-Time Messages', + value: `${humanNumber(planLimits.totalRealTimeMessageDeliveries)} messages`, + }, + planLimits.totalRealTimeMessageDeliveryBytes && { + label: 'Total Real-Time Message Transfer', + value: `${humanFileSize(planLimits.totalRealTimeMessageDeliveryBytes)}`, + }, + planLimits.realTimeMessageDeliveriesPerMinute && { + label: 'Real-Time Message Rate', + value: `${humanNumber(planLimits.realTimeMessageDeliveriesPerMinute * 60)}/min`, + }, + planLimits.realTimeMessageDeliveryBytesPerMinute && { + label: 'Real-Time Message Bandwidth', + value: `${humanFileSize(planLimits.realTimeMessageDeliveryBytesPerMinute * 60)}/min`, + }, + planLimits.tlsHandshakes && { + label: 'TLS Handshakes', + value: `${humanNumber(planLimits.tlsHandshakes * 60)}`, + }, + planLimits.applicationComputeHours && { + label: 'Application Compute Hours', + value: `${humanNumber(planLimits.applicationComputeHours * 60)}`, + }, + resourcesPerInstance?.storageGb && { label: 'Storage', - value: humanFileSize(resourcesPerInstance.storageGb, 1024 * 1024 * 1024), + value: `${humanFileSize(resourcesPerInstance.storageGb * 1000_000_000)}`, + }, + { + label: 'Expiration', + value: '3 months', }, - ].filter(excludeFalsy), [resourcesPerInstance]); + ].filter(excludeFalsy), [planLimits, resourcesPerInstance]); - return + return -
    +
    + This plan licenses Harper for the usage limits below, for the price listed above. The usage license expires in three months or when any usage + limit is reached. Usage blocks can be purchased as they are consumed. {rows.map((row, index) =>
    {row.label}
    diff --git a/src/features/clusters/upsert/index.tsx b/src/features/clusters/upsert/index.tsx index d80ece13e..8da9b630b 100644 --- a/src/features/clusters/upsert/index.tsx +++ b/src/features/clusters/upsert/index.tsx @@ -14,6 +14,7 @@ import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSch import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery'; import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'; import { SchemaPlan, SchemaRegion } from '@/lib/api.gen'; +import { sortByField } from '@/lib/arrays/sort/byField'; import { groupThenKeyBy } from '@/lib/groupThenKeyBy'; import { useQuery } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; @@ -33,9 +34,9 @@ export function UpsertCluster() { const { data: regionLocations } = useQuery(getRegionLocationsOptions(limitRegionParameters)); const deploymentToPerformanceToPlan = useMemo>>(() => - groupThenKeyBy(planTypes || [], 'deploymentDescription', 'performanceDescription'), [planTypes]); + groupThenKeyBy(planTypes?.sort(sortByField('priceUsd')) || [], 'deploymentDescription', 'performanceDescription'), [planTypes]); const regionNameToLatencyToRegion = useMemo>>(() => - groupThenKeyBy(regionLocations || [], 'region', 'latencyDescription'), [regionLocations]); + groupThenKeyBy(regionLocations?.sort(sortByField('latencyDescription')) || [], 'region', 'latencyDescription'), [regionLocations]); const defaultValues = useMemo>(() => { if (savedClusterState) { @@ -81,6 +82,7 @@ export function UpsertCluster() { } return { + autoRenew: cluster?.plans?.[0]?.autoRenew ?? true, systemName: cluster?.name ?? '', abbreviatedName: cluster?.abbreviatedName ?? '', deploymentDescription: selectedPlan?.deploymentDescription ?? defaults?.deploymentDescription ?? '', diff --git a/src/features/clusters/upsert/lib/calculateDefaultDeploymentPerformanceAndRegionPlans.ts b/src/features/clusters/upsert/lib/calculateDefaultDeploymentPerformanceAndRegionPlans.ts index 01118cd94..4ece76bbd 100644 --- a/src/features/clusters/upsert/lib/calculateDefaultDeploymentPerformanceAndRegionPlans.ts +++ b/src/features/clusters/upsert/lib/calculateDefaultDeploymentPerformanceAndRegionPlans.ts @@ -6,20 +6,21 @@ export function calculateDefaultDeploymentPerformanceAndRegionPlans( planTypes: SchemaPlan[], regionLocations: SchemaRegion[], ): null | Pick, 'deploymentDescription' | 'performanceDescription' | 'regionPlans'> { - const freeColocatedPlan = planTypes - .find(planType => !planType.priceUsd && planType.deploymentType === 'colocated'); - const allowedRegionIds = freeColocatedPlan?.allowedRegionIds; - if (freeColocatedPlan && allowedRegionIds?.length) { - const allowedFreeRegions = regionLocations.filter(regionLocation => allowedRegionIds.includes(regionLocation.id)); - const allowedGlobalFreeRegion = allowedFreeRegions.find(regionLocation => regionLocation.region === 'Global'); - if (allowedGlobalFreeRegion) { + const planToSelect = planTypes.find(planType => !planType.priceUsd && planType.deploymentType === 'colocated') + || planTypes.find(planType => planType.deploymentType === 'colocated') + || planTypes[0]; + const allowedRegionIds = planToSelect?.allowedRegionIds; + if (planToSelect) { + const allowedRegions = allowedRegionIds ? regionLocations.filter(regionLocation => allowedRegionIds.includes(regionLocation.id)) : regionLocations; + const regionToSelect = allowedRegions.find(regionLocation => regionLocation.region === 'Global') || allowedRegions[0]; + if (regionToSelect) { return { - deploymentDescription: freeColocatedPlan.deploymentDescription, - performanceDescription: freeColocatedPlan.performanceDescription, + deploymentDescription: planToSelect.deploymentDescription, + performanceDescription: planToSelect.performanceDescription, regionPlans: [ { - regionName: allowedGlobalFreeRegion.region, - latencyDescription: allowedGlobalFreeRegion.latencyDescription, + regionName: regionToSelect.region, + latencyDescription: regionToSelect.latencyDescription, }, ], }; diff --git a/src/features/clusters/upsert/upsertClusterSchema.tsx b/src/features/clusters/upsert/upsertClusterSchema.tsx index 2386c372d..d3f5ff3a2 100644 --- a/src/features/clusters/upsert/upsertClusterSchema.tsx +++ b/src/features/clusters/upsert/upsertClusterSchema.tsx @@ -6,6 +6,7 @@ export const UpsertClusterSchema = z.object({ systemName: z.string() .min(1, 'Must be at least 1 character long.') .max(255, 'Must be at most 255 characters long.'), + autoRenew: z.boolean(), abbreviatedName: z .string() .max(10, 'Must be at most 10 characters long.') diff --git a/src/lib/arrays/sort/byField.test.ts b/src/lib/arrays/sort/byField.test.ts new file mode 100644 index 000000000..447bfc20a --- /dev/null +++ b/src/lib/arrays/sort/byField.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { sortByField } from './byField'; + +describe('sortByField', () => { + it('sorts objects by a string field in ascending order', () => { + const items = [ + { id: 3, name: 'Charlie' }, + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + + const result = items.sort(sortByField('name')); + + expect(result.map((i) => i.name)).toEqual(['Alice', 'Bob', 'Charlie']); + }); + + it('sorts objects by a numeric field in ascending order', () => { + const items = [ + { label: 'c', value: 30 }, + { label: 'a', value: 10 }, + { label: 'b', value: 20 }, + ]; + + const result = items.sort(sortByField('value')); + + expect(result.map((i) => i.value)).toEqual([10, 20, 30]); + }); + + it('comparator returns 0 when the field values are equal', () => { + const cmp = sortByField<{ id: number }>('id'); + expect(cmp({ id: 1 }, { id: 1 })).toBe(0); + }); +}); diff --git a/src/lib/arrays/sort/byField.ts b/src/lib/arrays/sort/byField.ts new file mode 100644 index 000000000..c9f4269a7 --- /dev/null +++ b/src/lib/arrays/sort/byField.ts @@ -0,0 +1,8 @@ +export function sortByField(fieldName: keyof T): (a: T, b: T) => number { + return (a: T, b: T): number => { + if (a[fieldName] === b[fieldName]) { + return 0; + } + return a[fieldName] > b[fieldName] ? 1 : -1; + }; +} diff --git a/src/lib/arrays/sort/byNumberPrefix.test.ts b/src/lib/arrays/sort/byNumberPrefix.test.ts new file mode 100644 index 000000000..472f3d49a --- /dev/null +++ b/src/lib/arrays/sort/byNumberPrefix.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { sortByNumberPrefix } from './byNumberPrefix'; + +describe('sortByNumberPrefix', () => { + it('sorts strings by their leading numeric prefix in ascending order', () => { + const items = [ + '1000ms, small', + '230ms, medium', + '50ms, large', + '5ms, x-large', + ]; + + const result = items.sort(sortByNumberPrefix); + + expect(result).toEqual([ + '5ms, x-large', + '50ms, large', + '230ms, medium', + '1000ms, small', + ]); + }); + + it('returns 0 when the numeric prefixes are equal (current behavior)', () => { + expect(sortByNumberPrefix('10 apples', '10 bananas')).toBe(0); + }); + + it('treats strings without a numeric prefix as 0', () => { + // "apple" has no number prefix -> treated as 0; should come before positive numbers + const items = ['apple', '2 bananas', 'banana']; + const result = items.sort(sortByNumberPrefix); + + // "apple" and "banana" both treated as 0 and keep their relative order (sort is stable in modern engines) + expect(result[0]).toBe('apple'); + expect(result[1]).toBe('banana'); + expect(result[2]).toBe('2 bananas'); + + // direct comparator checks + expect(sortByNumberPrefix('apple', '2 bananas')).toBe(-1); + expect(sortByNumberPrefix('apple', 'banana')).toBe(0); + }); + + it('handles leading zeros correctly', () => { + const items = ['007sec, tiny', '7sec, small', '0008sec, medium']; + const result = items.sort(sortByNumberPrefix); + expect(result).toEqual(['007sec, tiny', '7sec, small', '0008sec, medium']); + }); +}); diff --git a/src/lib/arrays/sort/byNumberPrefix.ts b/src/lib/arrays/sort/byNumberPrefix.ts new file mode 100644 index 000000000..f5fa2f46e --- /dev/null +++ b/src/lib/arrays/sort/byNumberPrefix.ts @@ -0,0 +1,10 @@ +const numberPrefix = new RegExp(/^\d+/); + +export function sortByNumberPrefix(a: string, b: string): number { + const aPrefix = parseInt(a.match(numberPrefix)?.[0] || '0', 10); + const bPrefix = parseInt(b.match(numberPrefix)?.[0] || '0', 10); + if (aPrefix === bPrefix) { + return 0; + } + return aPrefix > bPrefix ? 1 : -1; +}