chore: improve plan items sorting, collapsible booleans, and product comparison utils

Made-with: Cursor
This commit is contained in:
Ayush Rodrigues
2026-04-21 14:55:35 +01:00
parent c52d3343d3
commit 7949ed2910
14 changed files with 373 additions and 91 deletions

View File

@@ -53,6 +53,7 @@ export * from "./productUtils/priceUtils";
export * from "./productV2Utils/mapToProductV2";
export * from "./productV2Utils/productItemUtils/classifyItemUtils";
export * from "./productV2Utils/productItemUtils/getItemType";
export * from "./productV2Utils/productItemUtils/sortPlanItems";
// Item utils
export * from "./productV2Utils/productItemUtils/mapToItem";
export * from "./productV2Utils/productItemUtils/productItemUtils";

View File

@@ -34,6 +34,7 @@ export const findSimilarItem = ({
if (isFeatureItem(item)) {
return items.find(
(i) =>
isFeatureItem(i) &&
i.feature_id === item.feature_id &&
entIntervalsSame({
intervalA: {
@@ -51,6 +52,7 @@ export const findSimilarItem = ({
if (isFeaturePriceItem(item)) {
return items.find(
(i) =>
isFeaturePriceItem(i) &&
i.feature_id === item.feature_id &&
intervalsSame({
intervalA: {

View File

@@ -164,11 +164,7 @@ export const productsAreSame = ({
if (items1.length !== items2.length) itemsSame = false;
// console.log(`items1: `, items1);
// console.log(`items2: `, items2);
for (const item of items1) {
// console.log(`base item: `, formatItem({ item, features }));
const similarItem = findSimilarItem({
item,
items: items2,
@@ -184,7 +180,6 @@ export const productsAreSame = ({
continue;
}
// console.log(`similar item: `, formatItem({ item: similarItem, features }));
const { same, pricesChanged: pricesChanged_ } = itemsAreSame({
item1: item,

View File

@@ -0,0 +1,109 @@
import type { ProductItem } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { UsageModel } from "../../../models/productV2Models/productItemModels/productItemModels.js";
import { notNullish } from "../../utils.js";
import { isBooleanFeatureItem, isFeaturePriceItem } from "./getItemType.js";
const BOOLEAN_COLLAPSE_THRESHOLD = 5;
/**
* Priority bucket for a plan item within a category group.
* Lower number = rendered first.
*/
function getItemPriority(item: ProductItem): number {
if (isFeaturePriceItem(item)) {
return item.usage_model === UsageModel.Prepaid ? 0 : 1;
}
if (isBooleanFeatureItem(item)) return 3;
// Metered feature without pricing (has included_usage or interval)
return 2;
}
function compareItems(a: ProductItem, b: ProductItem): number {
const priorityA = getItemPriority(a);
const priorityB = getItemPriority(b);
if (priorityA !== priorityB) return priorityA - priorityB;
// Within the same priority, group by feature_id so duplicates stay adjacent
const featureA = a.feature_id ?? "";
const featureB = b.feature_id ?? "";
return featureA.localeCompare(featureB);
}
/**
* Sort plan items into a consistent display order:
* 1. Non-entity items first (no entity_feature_id)
* 2. Entity-scoped items last, grouped by entity_feature_id
*
* Within each group the sub-order is:
* a. Priced features (prepaid before pay-per-use)
* b. Metered features without pricing
* c. Boolean features
*
* Items sharing the same feature_id are kept adjacent.
* Does not mutate the input array.
*/
export function sortPlanItems({
items,
}: {
items: ProductItem[];
}): ProductItem[] {
const nonEntity: ProductItem[] = [];
const entityGroups = new Map<string, ProductItem[]>();
for (const item of items) {
if (notNullish(item.entity_feature_id)) {
const group = entityGroups.get(item.entity_feature_id) ?? [];
group.push(item);
entityGroups.set(item.entity_feature_id, group);
} else {
nonEntity.push(item);
}
}
nonEntity.sort(compareItems);
const sortedEntityKeys = [...entityGroups.keys()].sort((a, b) =>
a.localeCompare(b),
);
const sortedEntityItems: ProductItem[] = [];
for (const key of sortedEntityKeys) {
const group = entityGroups.get(key);
if (!group) continue;
group.sort(compareItems);
sortedEntityItems.push(...group);
}
return [...nonEntity, ...sortedEntityItems];
}
/**
* Split already-sorted items into those rendered inline and boolean
* overflow items that should be collapsed behind an accordion.
*
* The first `BOOLEAN_COLLAPSE_THRESHOLD` boolean items stay visible;
* any beyond that are returned in `collapsedBooleanItems`.
*/
export function splitBooleanItems({ items }: { items: ProductItem[] }): {
visibleItems: ProductItem[];
collapsedBooleanItems: ProductItem[];
} {
let booleanCount = 0;
const visibleItems: ProductItem[] = [];
const collapsedBooleanItems: ProductItem[] = [];
for (const item of items) {
if (isBooleanFeatureItem(item)) {
booleanCount++;
if (booleanCount <= BOOLEAN_COLLAPSE_THRESHOLD) {
visibleItems.push(item);
} else {
collapsedBooleanItems.push(item);
}
} else {
visibleItems.push(item);
}
}
return { visibleItems, collapsedBooleanItems };
}

View File

@@ -4,12 +4,15 @@ import type {
FrontendProduct,
ProductItem,
} from "@autumn/shared";
import { sortPlanItems, splitBooleanItems } from "@autumn/shared";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { LayoutGroup, motion } from "motion/react";
import { useMemo } from "react";
import type { UseAttachForm } from "@/components/forms/attach-v2/hooks/useAttachForm";
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
import { Button } from "@/components/v2/buttons/Button";
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
import { CollapsedBooleanItems } from "./plan-items/CollapsedBooleanItems";
import { DeletedItemRow } from "./plan-items/DeletedItemRow";
import { PlanEditButton } from "./plan-items/PlanEditButton";
import { PlanItemRow } from "./plan-items/PlanItemRow";
@@ -94,6 +97,15 @@ export function PlanItemsSection({
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
) ?? []);
const sortedItems = useMemo(
() => sortPlanItems({ items: product?.items ?? [] }),
[product?.items],
);
const { visibleItems, collapsedBooleanItems } = useMemo(
() => splitBooleanItems({ items: sortedItems }),
[sortedItems],
);
const hasItems = (product?.items?.length ?? 0) > 0 || deletedItems.length > 0;
if (!hasItems) {
@@ -117,6 +129,9 @@ export function PlanItemsSection({
readOnly,
};
const itemKey = (item: ProductItem) =>
`${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`;
return (
<div>
<PlanPriceHeader
@@ -130,17 +145,30 @@ export function PlanItemsSection({
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
>
{product?.items?.map((item, index) => (
{visibleItems.map((item, index) => (
<PlanItemRow
key={`${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`}
key={itemKey(item)}
item={item}
index={index}
{...itemRowProps}
/>
))}
{collapsedBooleanItems.length > 0 && (
<CollapsedBooleanItems
items={collapsedBooleanItems}
renderItem={(item, index) => (
<PlanItemRow
key={itemKey(item)}
item={item}
index={visibleItems.length + index}
{...itemRowProps}
/>
)}
/>
)}
{deletedItems.map((item, index) => (
<DeletedItemRow
key={`deleted-${item.feature_id ?? ""}-${item.price_id ?? ""}-${item.interval ?? ""}-${item.interval_count ?? ""}`}
key={`deleted-${itemKey(item)}`}
item={item}
index={index}
/>

View File

@@ -0,0 +1,60 @@
import type { ProductItem } from "@autumn/shared";
import { type ReactNode, useState } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
import { motion } from "motion/react";
interface CollapsedBooleanItemsProps {
items: ProductItem[];
renderItem: (item: ProductItem, index: number) => ReactNode;
}
export function CollapsedBooleanItems({
items,
renderItem,
}: CollapsedBooleanItemsProps) {
const [value, setValue] = useState("");
if (items.length === 0) return null;
const isExpanded = value === "boolean-flags";
const label = isExpanded
? "Hide"
: `${items.length} more`;
return (
<Accordion
type="single"
collapsible
value={value}
onValueChange={setValue}
className="w-full"
>
<AccordionItem value="boolean-flags" className="border-none">
<AccordionTrigger className="py-2 px-3 rounded-xl text-t3 hover:bg-interative-secondary hover:no-underline">
<span className="text-sm font-normal">
{label} boolean flag{items.length === 1 ? "" : "s"}
</span>
</AccordionTrigger>
<AccordionContent className="pb-1.5 pt-1.5 px-0">
<div className="flex flex-col gap-1.5">
{items.map((item, index) => (
<motion.div
key={item.feature_id ?? index}
layout="position"
transition={{ layout: LAYOUT_TRANSITION }}
>
{renderItem(item, index)}
</motion.div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -1,4 +1,5 @@
import type { FrontendProduct, ProductItem } from "@autumn/shared";
import { sortPlanItems } from "@autumn/shared";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { useItemDraftController } from "@/hooks/inline-editor/useItemDraftController";
import { ProductProvider } from "./PlanEditorContext";
@@ -22,8 +23,16 @@ interface InlineEditorProviderProps {
*/
export function InlineEditorProvider({
children,
initialProduct,
initialProduct: initialProductProp,
}: InlineEditorProviderProps) {
const initialProduct = useMemo<FrontendProduct>(
() => ({
...initialProductProp,
items: sortPlanItems({ items: initialProductProp.items }),
}),
[initialProductProp],
);
const [sheetType, setSheetType] = useState<SheetType>(null);
const [itemId, setItemId] = useState<string | null>(null);
const [initialItem, setInitialItemState] = useState<ProductItem | null>(null);

View File

@@ -1,4 +1,4 @@
import type { FrontendProduct } from "@autumn/shared";
import { type FrontendProduct, sortPlanItems } from "@autumn/shared";
import { AnimatePresence, motion } from "motion/react";
import { createPortal } from "react-dom";
import { Button } from "@/components/v2/buttons/Button";
@@ -88,7 +88,12 @@ function InlinePlanEditorContent({
{hasPlanChanges && (
<ShortcutButton
metaShortcut="s"
onClick={() => onSave(product)}
onClick={() =>
onSave({
...product,
items: sortPlanItems({ items: product.items }),
})
}
>
Save Changes
</ShortcutButton>

View File

@@ -78,11 +78,12 @@ export const useHasChanges = () => {
features,
});
return (
const hasChanges =
!comparison.itemsSame ||
!comparison.detailsSame ||
!comparison.freeTrialsSame
);
!comparison.freeTrialsSame;
return hasChanges;
}, [product, baseProduct, features]);
};

View File

@@ -1,5 +1,5 @@
import type { FrontendProduct, ProductV2 } from "@autumn/shared";
import { productV2ToFrontendProduct } from "@autumn/shared";
import { productV2ToFrontendProduct, sortPlanItems } from "@autumn/shared";
import { useEffect, useRef } from "react";
import { useProductStore } from "./useProductStore";
@@ -29,8 +29,11 @@ export const useProductSync = ({
if (isNewProduct || isProductUpdated) {
lastProductRef.current = product;
// Convert ProductV2 to FrontendProduct
const frontendProduct = productV2ToFrontendProduct({ product });
const converted = productV2ToFrontendProduct({ product });
const frontendProduct: FrontendProduct = {
...converted,
items: sortPlanItems({ items: converted.items }),
};
// Always update baseProduct to reflect backend state
setBaseProduct(frontendProduct);

View File

@@ -315,7 +315,7 @@ function SelectContent() {
{entityId ? (
<div className="pt-2">
<InfoBox variant="info">
<InfoBox variant="note">
Attaching plan to entity{" "}
<span className="font-semibold">
{fullEntity?.name || fullEntity?.id}
@@ -324,7 +324,7 @@ function SelectContent() {
</div>
) : entities.length > 0 ? (
<div className="pt-2">
<InfoBox variant="info">
<InfoBox variant="note">
Attaching plan to customer - all entities will get access
</InfoBox>
</div>

View File

@@ -1,8 +1,11 @@
import {
CusProductStatus,
type Entity,
type FrontendProduct,
isCustomerProductTrialing,
type ProductItem,
sortPlanItems,
splitBooleanItems,
UsageModel,
} from "@autumn/shared";
import {
@@ -20,6 +23,8 @@ import {
XCircle,
} from "@phosphor-icons/react";
import { format } from "date-fns";
import { useMemo } from "react";
import { CollapsedBooleanItems } from "@/components/forms/shared/plan-items/CollapsedBooleanItems";
import { Button } from "@/components/v2/buttons/Button";
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
@@ -39,6 +44,60 @@ import { BasePriceDisplay } from "@/views/products/plan/components/plan-card/Bas
import { PlanFeatureRow } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
import { CustomerProductsStatus } from "../table/customer-products/CustomerProductsStatus";
function SubscriptionDetailItems({
items,
product,
prepaidDisplayQuantities,
}: {
items: ProductItem[];
product: FrontendProduct;
prepaidDisplayQuantities: Record<string, number>;
}) {
const sortedItems = useMemo(() => sortPlanItems({ items }), [items]);
const { visibleItems, collapsedBooleanItems } = useMemo(
() => splitBooleanItems({ items: sortedItems }),
[sortedItems],
);
const renderRow = (item: ProductItem, index: number) => {
if (!item.feature_id) return null;
const prepaidQuantity =
item.usage_model === UsageModel.Prepaid
? (prepaidDisplayQuantities[item.feature_id] ?? null)
: null;
return (
<PlanFeatureRow
key={item.feature_id || item.price_id || index}
item={item}
index={index}
readOnly={true}
prepaidQuantity={prepaidQuantity}
/>
);
};
return (
<SheetSection>
<div className="flex gap-2 justify-between items-center h-6 mb-3">
<BasePriceDisplay product={product} readOnly={true} />
</div>
<div className="space-y-2">
{visibleItems.map((item, index) => renderRow(item, index))}
{collapsedBooleanItems.length > 0 && (
<CollapsedBooleanItems
items={collapsedBooleanItems}
renderItem={(item, index) =>
renderRow(item, visibleItems.length + index)
}
/>
)}
</div>
</SheetSection>
);
}
export function SubscriptionDetailSheet() {
const { customer } = useCusQuery();
const { stripeAccount } = useOrgStripeQuery();
@@ -121,33 +180,11 @@ export function SubscriptionDetailSheet() {
/>
{productV2?.items && productV2.items.length > 0 && (
<SheetSection>
{productV2 && (
<div className="flex gap-2 justify-between items-center h-6 mb-3">
<BasePriceDisplay product={productV2} readOnly={true} />
</div>
)}
<div className="space-y-2">
{productV2.items.map((item: ProductItem, index: number) => {
if (!item.feature_id) return null;
const prepaidQuantity =
item.usage_model === UsageModel.Prepaid
? (prepaidDisplayQuantities[item.feature_id] ?? null)
: null;
return (
<PlanFeatureRow
key={item.feature_id || item.price_id || index}
item={item}
index={index}
readOnly={true}
prepaidQuantity={prepaidQuantity}
/>
);
})}
</div>
</SheetSection>
<SubscriptionDetailItems
items={productV2.items}
product={productV2}
prepaidDisplayQuantities={prepaidDisplayQuantities}
/>
)}
<SheetSection withSeparator={true}>

View File

@@ -1,4 +1,11 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import {
type ProductItem,
productV2ToFeatureItems,
sortPlanItems,
splitBooleanItems,
} from "@autumn/shared";
import { useMemo } from "react";
import { CollapsedBooleanItems } from "@/components/forms/shared/plan-items/CollapsedBooleanItems";
import {
useProduct,
useSheet,
@@ -9,6 +16,16 @@ import { AddFeatureRow } from "./AddFeatureRow";
import { DummyPlanFeatureRow } from "./DummyPlanFeatureRow";
import { PlanFeatureRow } from "./PlanFeatureRow";
function EntityGroupHeader({ entityFeatureId }: { entityFeatureId: string }) {
const { features } = useFeaturesQuery();
const feature = features.find((f) => f.id === entityFeatureId);
return (
<div className="text-sm font-medium text-body-secondary px-2 pt-2">
{feature?.name || entityFeatureId}
</div>
);
}
export const PlanFeatureList = ({
allowAddFeature = true,
}: {
@@ -16,25 +33,27 @@ export const PlanFeatureList = ({
}) => {
const { product, setProduct } = useProduct();
const { sheetType, itemId, setSheet } = useSheet();
const { features } = useFeaturesQuery();
const isCreatingFeature = sheetType === "new-feature" || itemId === "new";
const isAddButtonDisabled =
isCreatingFeature || sheetType === "select-feature";
const filteredItems = useMemo(
() => (product ? productV2ToFeatureItems({ items: product.items }) : []),
[product],
);
const sortedItems = useMemo(
() => sortPlanItems({ items: filteredItems }),
[filteredItems],
);
const { visibleItems, collapsedBooleanItems } = useMemo(
() => splitBooleanItems({ items: sortedItems }),
[sortedItems],
);
if (!product) return null;
const filteredItems = productV2ToFeatureItems({ items: product.items });
const groupedItems = filteredItems.reduce(
(acc, item) => {
const key = item.entity_feature_id || "no_entity";
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
},
{} as Record<string, ProductItem[]>,
);
const hasEntityItems = sortedItems.some((i) => i.entity_feature_id);
const handleDelete = (item: ProductItem) => {
if (!product.items) return;
@@ -73,41 +92,52 @@ export const PlanFeatureList = ({
);
}
const groups = Object.entries(groupedItems).sort(([keyA], [keyB]) => {
if (keyA === "no_entity") return -1;
if (keyB === "no_entity") return 1;
return 0;
});
const hasEntityFeatureIds = groups.some(([key]) => key !== "no_entity");
const renderFeatureRow = (item: ProductItem) => {
const itemIndex = product.items?.indexOf(item) ?? -1;
return (
<PlanFeatureRow
key={item.entitlement_id || item.price_id || itemIndex}
item={item}
index={itemIndex}
onDelete={handleDelete}
/>
);
};
const renderVisibleItems = () => {
const elements: React.ReactNode[] = [];
let lastEntityId: string | null | undefined;
for (const item of visibleItems) {
if (
hasEntityItems &&
item.entity_feature_id &&
item.entity_feature_id !== lastEntityId
) {
elements.push(
<EntityGroupHeader
key={`header-${item.entity_feature_id}`}
entityFeatureId={item.entity_feature_id}
/>,
);
}
lastEntityId = item.entity_feature_id;
elements.push(renderFeatureRow(item));
}
return elements;
};
return (
<div className="space-y-2">
{groups.map(([entityFeatureId, items]) => {
const feature = features.find((f) => f.id === entityFeatureId);
const showHeader =
hasEntityFeatureIds && entityFeatureId !== "no_entity";
{renderVisibleItems()}
return (
<div key={entityFeatureId} className="space-y-2">
{showHeader && (
<div className="text-sm font-medium text-body-secondary px-2 pt-2">
{feature?.name || entityFeatureId}
</div>
)}
{items.map((item: ProductItem) => {
const itemIndex = product.items?.indexOf(item) ?? -1;
return (
<PlanFeatureRow
key={item.entitlement_id || item.price_id || itemIndex}
item={item}
index={itemIndex}
onDelete={handleDelete}
/>
);
})}
</div>
);
})}
{collapsedBooleanItems.length > 0 && (
<CollapsedBooleanItems
items={collapsedBooleanItems}
renderItem={(item) => renderFeatureRow(item)}
/>
)}
{allowAddFeature &&
(isCreatingNewFeature ? (

View File

@@ -1,5 +1,6 @@
import {
type FrontendProductItem,
sortPlanItems,
type UpdateProductV2Params,
UpdateProductV2ParamsSchema,
} from "@autumn/shared";
@@ -30,9 +31,10 @@ export const updateProduct = async ({
}
try {
const sortedItems = sortPlanItems({ items: product.items });
const updateData = UpdateProductV2ParamsSchema.parse({
...product,
items: product.items,
items: sortedItems,
free_trial: product.free_trial,
});