diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index 36d819228..e26122959 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -96,6 +96,7 @@ export const useMigrationsQuery = () => { limit?: number; only?: string[]; concurrency?: number; + lazy_run?: boolean; }) => { const { data } = await axiosInstance.post<{ migration_id: string; diff --git a/vite/src/views/migrations/migration-list/MigrationListTable.tsx b/vite/src/views/migrations/migration-list/MigrationListTable.tsx index 44c9db333..a687ef7a7 100644 --- a/vite/src/views/migrations/migration-list/MigrationListTable.tsx +++ b/vite/src/views/migrations/migration-list/MigrationListTable.tsx @@ -23,12 +23,10 @@ export function MigrationListTable() { }, }); - const hasRows = table.getRowModel().rows.length > 0; - const getRowHref = (row: Migration) => pushPage({ path: `/migrations/${row.id}` }); - if (!isLoading && !hasRows) { + if (!isLoading && migrations.length === 0) { return ( [ diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index de8381518..f380510eb 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -8,6 +8,7 @@ import { CaretDownIcon, CaretLeftIcon, CaretRightIcon, + CheckIcon, EyeIcon, ListMagnifyingGlassIcon, PlayIcon, @@ -23,6 +24,7 @@ import { Table } from "@/components/general/table"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { Dialog, DialogContent, @@ -165,6 +167,13 @@ export function MigrationLiveView({ const [dismissedError, setDismissedError] = useState(null); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [sample, setSample] = useState({ + open: false, + mode: "limit" as "limit" | "select", + limit: "10", + customerIds: [] as string[], + running: null as "dry" | "live" | null, + }); const { cancelRun, isCanceling } = useMigrationsQuery(); const debouncedSetSearch = useMemo( @@ -384,7 +393,7 @@ export function MigrationLiveView({ + + +
+ {sample.mode === "limit" ? ( +
+ + + setSample((s) => ({ + ...s, + limit: e.target.value, + })) + } + placeholder="10" + /> + + + {Math.min( + Number(sample.limit) || 0, + enrichedCustomers.filter((c) => !c._event).length, + )}{" "} + customers + +
+ ) : ( +
+ + + setSample((s) => ({ ...s, customerIds: ids })) + } + /> +
+ )} +
+ + + { + setSample((s) => ({ ...s, running: "dry" })); + if (sample.mode === "limit") { + const topIds = enrichedCustomers + .filter((c) => !c._event) + .slice(0, Number(sample.limit)) + .map((c) => c.id ?? c.internal_id); + await triggerRun({ + dryRun: true, + only: topIds, + }); + } else { + await triggerRun({ + dryRun: true, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Dry Run{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + { + setSample((s) => ({ ...s, running: "live" })); + if (sample.mode === "limit") { + await triggerRun({ + dryRun: false, + limit: Number(sample.limit), + }); + } else { + await triggerRun({ + dryRun: false, + only: sample.customerIds, + }); + } + setSample((s) => ({ ...s, running: null, open: false })); + }} + > + + Run Live{" "} + {sample.mode === "limit" + ? `(${sample.limit || 0})` + : `(${sample.customerIds.length})`} + + + +
@@ -596,3 +766,127 @@ export function MigrationLiveView({
); } + +function SampleCustomerPreview({ + customers, + limit, +}: { + customers: CustomerRow[]; + limit: number; +}) { + const unrun = customers.filter((c) => !c._event); + const previewed = unrun.slice(0, limit); + if (limit === 0) return null; + return ( +
+ {previewed.length === 0 ? ( +
+ No customers to preview +
+ ) : ( + previewed.map((c) => ( +
+ + {c.name || c.id || c.internal_id} + + {c.email && ( + + {c.email} + + )} +
+ )) + )} +
+ ); +} + +function SampleCustomerPicker({ + customers, + selectedIds, + onChange, +}: { + customers: CustomerRow[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const [search, setSearch] = useState(""); + const filtered = useMemo(() => { + if (!search) return customers; + const q = search.toLowerCase(); + return customers.filter( + (c) => + c.name?.toLowerCase().includes(q) || + c.id?.toLowerCase().includes(q) || + c.email?.toLowerCase().includes(q), + ); + }, [customers, search]); + + const toggle = (id: string) => { + onChange( + selectedIds.includes(id) + ? selectedIds.filter((v) => v !== id) + : [...selectedIds, id], + ); + }; + + return ( +
+ setSearch(e.target.value)} + placeholder="Search customers..." + className="text-sm" + /> +
+ {filtered.length === 0 ? ( +
+ No customers found +
+ ) : ( + filtered.map((c) => { + const isSelected = selectedIds.includes(c.id ?? c.internal_id); + return ( + + ); + }) + )} +
+ + {selectedIds.length} selected + +
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/OperationsForm.tsx b/vite/src/views/migrations/migration/operations/OperationsForm.tsx index 43beb28a3..361d94faf 100644 --- a/vite/src/views/migrations/migration/operations/OperationsForm.tsx +++ b/vite/src/views/migrations/migration/operations/OperationsForm.tsx @@ -10,6 +10,7 @@ import { CheckIcon, PackageIcon, PencilSimpleIcon, + PlusIcon, } from "@phosphor-icons/react"; import { @@ -19,6 +20,7 @@ import { DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { ActionCard } from "../shared/ActionCard"; +import { DASHED_BUTTON_CLASS } from "../shared/AddButton"; import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks"; import { AddPlansSection } from "./AddPlanOpForm"; import { UpdatePlanOpForm } from "./UpdatePlanOpForm"; @@ -184,45 +186,41 @@ export function OperationsForm({ )} -
- Add Operation -
- - } - heading="Update Plan" - subheading="Modify existing customer plans" - onClick={() => - setOperations([...operations, DEFAULT_UPDATE_PLAN]) - } - className="flex-1" - /> - {!hasAddPlans && ( - - } - heading="Add Plan" - subheading="Assign a new plan to customers" +
+ + + + Add Operation + + + - setOperations([ - ...operations, - { type: "add_plan", plan_id: "" }, - ]) + setOperations([...operations, DEFAULT_UPDATE_PLAN]) } - className="flex-1" - /> - )} -
+ > + + Update Plan + + {!hasAddPlans && ( + + setOperations([ + ...operations, + { type: "add_plan", plan_id: "" }, + ]) + } + > + + Add Plan + + )} + +
)} diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index d2bc70415..596dfb2a6 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -42,18 +42,21 @@ import { import { RemoveItemRows } from "./RemoveItemRows"; function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) { - const { products } = useProductsQuery(); + const { products } = useProductsQuery({ allVersions: true }); - const targetPlanId = - typeof planFilter.plan_id === "string" && planFilter.plan_id - ? planFilter.plan_id - : null; + const targetIds = extractPlanIds(planFilter.plan_id); + const idSet = new Set(targetIds); - const matchingProducts = targetPlanId - ? products.filter((p) => p.id === targetPlanId) - : []; + const matchingProducts = + idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : []; + const seen = new Set(); return matchingProducts + .filter((p) => { + if (seen.has(p.version)) return false; + seen.add(p.version); + return true; + }) .map((p) => ({ value: String(p.version), label: `v${p.version}`,