From 624dadafadb4b53db97d14373cecc87eaf4ebc39 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 27 Aug 2025 13:24:05 -0600 Subject: [PATCH 01/12] Updated descriptions of the usage plan/pricing --- .../clusters/upsert/ClusterDetails.tsx | 6 +- .../components/ResourcesPerInstance.tsx | 89 ++++++++++++++----- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index a667eccbe..4cb5f561f 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -127,7 +127,7 @@ export function ClusterDetails({ name="performanceDescription" render={({ field }) => ( - Performance & Usage + {selectedDeployment.startsWith('Self') ? 'Support' : 'Performance'} & Usage }> @@ -225,8 +225,8 @@ export function ClusterDetails({ />) } - {selectedPlan?.resourcesPerInstance && ( - + {selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && ( + )} diff --git a/src/features/clusters/upsert/components/ResourcesPerInstance.tsx b/src/features/clusters/upsert/components/ResourcesPerInstance.tsx index ed21d2d06..08557a217 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,31 +21,71 @@ 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 @@ -54,12 +95,16 @@ export function ResourcesPerInstance({ resourcesPerInstance }: { className="text-white cursor-pointer" onClick={onUsageLimitsClick} > - Usage Limits + Purchasing usage block for {humanNumber(planLimits.readsPerMinuteCount!)} reads/min & + {humanNumber(planLimits.totalReadCount)} total reads per region,
+ {humanNumber(planLimits.writesPerMinuteCount!)} writes/min & {humanNumber(planLimits.totalWriteCount!)} total writes, for 3 months, expand for more details: {toggled ? : }
-
+
+ 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}
From 4995ad7b693a892c4c3b059bb917dd53bf9d5a01 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 16:41:00 -0400 Subject: [PATCH 02/12] Add auto renew checkbox --- .../clusters/upsert/ClusterDetails.tsx | 22 ++++++++++++++++++- src/features/clusters/upsert/ClusterForm.tsx | 10 ++++----- src/features/clusters/upsert/index.tsx | 1 + .../clusters/upsert/upsertClusterSchema.tsx | 1 + 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index 4cb5f561f..bacf084af 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -69,7 +69,7 @@ export function ClusterDetails({ control={form.control} name="systemName" render={({ field }) => ( - + Harper System Name + ( + + Auto Renew + + + + + + )} + /> + Date: Wed, 27 Aug 2025 16:41:12 -0400 Subject: [PATCH 03/12] Show resources per instance on billing confirmation page --- src/features/clusters/upsert/ClusterBilling.tsx | 8 ++++++++ src/features/clusters/upsert/ClusterForm.tsx | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx index 2eeb70354..69480caf8 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,7 @@ interface ClusterBillingProps { readonly onSaveStateForBillingRedirect: (redirecting: boolean) => void; readonly onSubmit?: () => void; readonly organizationId: string; + readonly selectedPlan: SchemaPlan | undefined; } export function ClusterBilling({ @@ -22,6 +25,7 @@ export function ClusterBilling({ onSaveStateForBillingRedirect, onSubmit, organizationId, + selectedPlan, }: ClusterBillingProps) { const { data: organization } = useQuery(getOrganizationQueryOptions(organizationId)); const billing = organization?.billing; @@ -85,6 +89,10 @@ export function ClusterBilling({ + {selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && ( + + )} +

Payment method:

diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index 807354e1d..63f54f1a1 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -334,12 +334,13 @@ export function ClusterForm({ details:

) } From 53ccb54a42d83a6ec76c506df94724df5869f365 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 16:41:28 -0400 Subject: [PATCH 04/12] Support smaller viewport on resources per instance and tweak spacing --- .../clusters/upsert/components/ResourcesPerInstance.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/clusters/upsert/components/ResourcesPerInstance.tsx b/src/features/clusters/upsert/components/ResourcesPerInstance.tsx index 08557a217..e83c2a033 100644 --- a/src/features/clusters/upsert/components/ResourcesPerInstance.tsx +++ b/src/features/clusters/upsert/components/ResourcesPerInstance.tsx @@ -87,7 +87,7 @@ export function ResourcesPerInstance({ planLimits, resourcesPerInstance }: { }, ].filter(excludeFalsy), [planLimits, resourcesPerInstance]); - return + return From 4eb6f54f0293edd95dbd4d1cbe07668c0cfb5116 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 16:47:30 -0400 Subject: [PATCH 05/12] Describe auto renewal on the billing confirmation page https://harperdb.atlassian.net/browse/STUDIO-257 --- src/features/clusters/upsert/ClusterBilling.tsx | 7 +++++-- src/features/clusters/upsert/ClusterForm.tsx | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx index 69480caf8..9548a99e1 100644 --- a/src/features/clusters/upsert/ClusterBilling.tsx +++ b/src/features/clusters/upsert/ClusterBilling.tsx @@ -15,6 +15,7 @@ interface ClusterBillingProps { readonly onSaveStateForBillingRedirect: (redirecting: boolean) => void; readonly onSubmit?: () => void; readonly organizationId: string; + readonly selectedAutoRenew: boolean; readonly selectedPlan: SchemaPlan | undefined; } @@ -25,6 +26,7 @@ export function ClusterBilling({ onSaveStateForBillingRedirect, onSubmit, organizationId, + selectedAutoRenew, selectedPlan, }: ClusterBillingProps) { const { data: organization } = useQuery(getOrganizationQueryOptions(organizationId)); @@ -84,8 +86,9 @@ export function ClusterBilling({
  • You will be billed for this cluster today, and will receive a license for the block of usage you've requested.
  • -
  • When that block is used up, or 3 months elapse, you will be automatically - renewed. +
  • {selectedAutoRenew + ? 'When that block is used up, or 3 months elapse, you will be automatically renewed.' + : 'When that block is used up, or 3 months elapse, you will NOT be automatically renewed.'}
  • diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index 63f54f1a1..f8b95ba2d 100644 --- a/src/features/clusters/upsert/ClusterForm.tsx +++ b/src/features/clusters/upsert/ClusterForm.tsx @@ -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({ @@ -340,6 +341,7 @@ export function ClusterForm({ onSaveStateForBillingRedirect={onSaveStateForBillingRedirect} onSubmit={submitCreateCluster} organizationId={organizationId} + selectedAutoRenew={selectedAutoRenew} selectedPlan={selectedPlan} /> ) From 47dea18281d8054a994cf746e3f7fe9ce5042c64 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 16:53:49 -0400 Subject: [PATCH 06/12] Add more text around billing confirmation https://harperdb.atlassian.net/browse/STUDIO-264 --- .../clusters/upsert/ClusterBilling.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx index 9548a99e1..f04bf8798 100644 --- a/src/features/clusters/upsert/ClusterBilling.tsx +++ b/src/features/clusters/upsert/ClusterBilling.tsx @@ -60,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.
  • @@ -83,13 +83,25 @@ export function ClusterBilling({ return (<>
      -
    • You will be billed for this cluster today, and will receive a license for the - block of usage you've requested. +
    • You will be billed for this cluster today, and will receive a license for the block of usage you've + requested.
    • + {clusterId && ( +
    • If you scale up, you'll be charged for the additional blocks you've purchased + now${selectedAutoRenew ? ', and your next auto renewal will be for all purchased blocks' : ''}. +
    • )} + {clusterId && (
    • If you remove a region, that usage block will not be used (because it is specific to that + region).
    • )}
    • {selectedAutoRenew ? 'When that block is used up, or 3 months elapse, you will be automatically renewed.' : 'When that block is used up, or 3 months elapse, you will NOT be automatically renewed.'}
    • +
    • No refunds will be issued.
    • +
    • We would love to work with you to sort out more precise details, and to help accomplish your objectives + with this + cluster. Contact + us, we are here to help. +
    {selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && ( From f336307dcd44ff5a1594ec682421a5002f209a60 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 16:57:55 -0400 Subject: [PATCH 07/12] Adjust refund kindness --- src/features/clusters/upsert/ClusterBilling.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/clusters/upsert/ClusterBilling.tsx b/src/features/clusters/upsert/ClusterBilling.tsx index f04bf8798..494b6fee9 100644 --- a/src/features/clusters/upsert/ClusterBilling.tsx +++ b/src/features/clusters/upsert/ClusterBilling.tsx @@ -96,7 +96,8 @@ export function ClusterBilling({ ? 'When that block is used up, or 3 months elapse, you will be automatically renewed.' : 'When that block is used up, or 3 months elapse, you will NOT be automatically renewed.'} -
  • No refunds will be issued.
  • +
  • While refunds are not available, we’d be happy to assist you with... swag, depending on availability. +
  • We would love to work with you to sort out more precise details, and to help accomplish your objectives with this cluster. Contact From def833b6a786628fcf735d229b62d3fbcc8a6ca2 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 27 Aug 2025 17:08:40 -0400 Subject: [PATCH 08/12] =?UTF-8?q?Fallback=20if=20we=20don=E2=80=99t=20find?= =?UTF-8?q?=20the=20free=20tier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://harperdb.atlassian.net/browse/STUDIO-264 --- ...aultDeploymentPerformanceAndRegionPlans.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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, }, ], }; From e9a4cce026fa94d17f2f7f08ef3c0c4217a703bb Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 28 Aug 2025 10:32:45 -0400 Subject: [PATCH 09/12] Disable cluster submit button when invalid or pristine --- src/features/clusters/upsert/ClusterDetails.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index bacf084af..e4b3b1dd8 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,6 +45,7 @@ export function ClusterDetails({ selectedPlan, totalPrice, }: ClusterDetailsProps) { + const { isDirty, isValid } = useFormState(); const availablePerformanceDescriptions = useMemo(() => Object.keys(deploymentToPerformanceToPlan[selectedDeployment] || {}), [deploymentToPerformanceToPlan, selectedDeployment]); const availableDeploymentTypes = useMemo(() => @@ -250,7 +251,7 @@ export function ClusterDetails({ )}
  • - From 57700374f005696add9584f888a12e9c1a2b13fd Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 28 Aug 2025 12:26:57 -0400 Subject: [PATCH 10/12] Fix self-hosted references --- src/features/clusters/upsert/ClusterDetails.tsx | 2 +- src/features/clusters/upsert/ClusterForm.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index e4b3b1dd8..f0b4c84dc 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -62,7 +62,7 @@ export function ClusterDetails({ } }, [selectedDeployment, selectedPerformance, availablePerformanceDescriptions, form]); - const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration'; + const isSelfManaged = selectedDeployment === 'Self-Hosted'; return (<>
    diff --git a/src/features/clusters/upsert/ClusterForm.tsx b/src/features/clusters/upsert/ClusterForm.tsx index f8b95ba2d..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]); @@ -141,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; } @@ -205,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]; @@ -218,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!]; @@ -244,7 +244,7 @@ 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({ From f52b0bb5fbb4cb69d8634da3bac3a70a94edcac4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 28 Aug 2025 12:27:22 -0400 Subject: [PATCH 11/12] Only allow adding regions until we run out of regions https://harperdb.atlassian.net/browse/STUDIO-257 --- .../clusters/upsert/ClusterRegions.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 && (
    -
    +
    )} ); } From 72213b52b6433e20c4bc989bc67a3eee3e8ad701 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 28 Aug 2025 12:27:40 -0400 Subject: [PATCH 12/12] Sort out sorting of various cluster selects https://harperdb.atlassian.net/browse/STUDIO-257 --- .../clusters/upsert/ClusterDetails.tsx | 6 +-- .../upsert/components/RegionFormInputs.tsx | 9 ++-- src/features/clusters/upsert/index.tsx | 5 +- src/lib/arrays/sort/byField.test.ts | 33 +++++++++++++ src/lib/arrays/sort/byField.ts | 8 ++++ src/lib/arrays/sort/byNumberPrefix.test.ts | 47 +++++++++++++++++++ src/lib/arrays/sort/byNumberPrefix.ts | 10 ++++ 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 src/lib/arrays/sort/byField.test.ts create mode 100644 src/lib/arrays/sort/byField.ts create mode 100644 src/lib/arrays/sort/byNumberPrefix.test.ts create mode 100644 src/lib/arrays/sort/byNumberPrefix.ts diff --git a/src/features/clusters/upsert/ClusterDetails.tsx b/src/features/clusters/upsert/ClusterDetails.tsx index f0b4c84dc..3727fb294 100644 --- a/src/features/clusters/upsert/ClusterDetails.tsx +++ b/src/features/clusters/upsert/ClusterDetails.tsx @@ -49,11 +49,7 @@ export function ClusterDetails({ 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)) { 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/index.tsx b/src/features/clusters/upsert/index.tsx index c50ee83d6..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) { 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; +}