Merge pull request #1605 from useautumn/charlie/migration-ui-cleanup
chore: cleanup migration ui
This commit is contained in:
@@ -96,6 +96,7 @@ export const useMigrationsQuery = () => {
|
||||
limit?: number;
|
||||
only?: string[];
|
||||
concurrency?: number;
|
||||
lazy_run?: boolean;
|
||||
}) => {
|
||||
const { data } = await axiosInstance.post<{
|
||||
migration_id: string;
|
||||
|
||||
@@ -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 (
|
||||
<EmptyState
|
||||
type="migrations"
|
||||
|
||||
@@ -42,6 +42,7 @@ export function useRealtimeSubscriptions({
|
||||
dry_run: dryRun,
|
||||
limit,
|
||||
only,
|
||||
lazy_run: true,
|
||||
});
|
||||
if (result.trigger_run_id && result.public_access_token) {
|
||||
setSubscriptions((prev) => [
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<Button
|
||||
variant="primary"
|
||||
size="default"
|
||||
className="rounded-r-none"
|
||||
className="rounded-r-none border-r-0"
|
||||
onClick={() => setIsRunDialogOpen(true)}
|
||||
isLoading={isRunning}
|
||||
>
|
||||
@@ -407,10 +416,10 @@ export function MigrationLiveView({
|
||||
Dry Run All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => triggerRun({ dryRun: false, limit: 10 })}
|
||||
onClick={() => setSample((s) => ({ ...s, open: true }))}
|
||||
>
|
||||
<PlayIcon size={14} weight="fill" />
|
||||
Sample (10)
|
||||
Run Sample
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -502,6 +511,167 @@ export function MigrationLiveView({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={sample.open}
|
||||
onOpenChange={(open) => setSample((s) => ({ ...s, open }))}
|
||||
>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Run Sample</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run the migration on a subset of customers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSample((s) => ({ ...s, mode: "limit" }))
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||
sample.mode === "limit"
|
||||
? "border-primary text-t1"
|
||||
: "border-transparent text-t3 hover:text-t2",
|
||||
)}
|
||||
>
|
||||
By count
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSample((s) => ({ ...s, mode: "select" }))
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||
sample.mode === "select"
|
||||
? "border-primary text-t1"
|
||||
: "border-transparent text-t3 hover:text-t2",
|
||||
)}
|
||||
>
|
||||
Select customers
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-[220px]">
|
||||
{sample.mode === "limit" ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-t3">
|
||||
Number of customers
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={sample.limit}
|
||||
onChange={(e) =>
|
||||
setSample((s) => ({
|
||||
...s,
|
||||
limit: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="10"
|
||||
/>
|
||||
<SampleCustomerPreview
|
||||
customers={enrichedCustomers}
|
||||
limit={Number(sample.limit) || 0}
|
||||
/>
|
||||
<span className="text-xs text-t3">
|
||||
{Math.min(
|
||||
Number(sample.limit) || 0,
|
||||
enrichedCustomers.filter((c) => !c._event).length,
|
||||
)}{" "}
|
||||
customers
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-t3">
|
||||
Select customers to run
|
||||
</label>
|
||||
<SampleCustomerPicker
|
||||
customers={enrichedCustomers}
|
||||
selectedIds={sample.customerIds}
|
||||
onChange={(ids) =>
|
||||
setSample((s) => ({ ...s, customerIds: ids }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="sm:flex-col gap-2">
|
||||
<ShortcutButton
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
isLoading={sample.running === "dry"}
|
||||
disabled={
|
||||
sample.running !== null ||
|
||||
(sample.mode === "limit"
|
||||
? !sample.limit || Number(sample.limit) < 1
|
||||
: sample.customerIds.length === 0)
|
||||
}
|
||||
onClick={async () => {
|
||||
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 }));
|
||||
}}
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
Dry Run{" "}
|
||||
{sample.mode === "limit"
|
||||
? `(${sample.limit || 0})`
|
||||
: `(${sample.customerIds.length})`}
|
||||
</ShortcutButton>
|
||||
<ShortcutButton
|
||||
className="w-full"
|
||||
metaShortcut="enter"
|
||||
isLoading={sample.running === "live"}
|
||||
disabled={
|
||||
sample.running !== null ||
|
||||
(sample.mode === "limit"
|
||||
? !sample.limit || Number(sample.limit) < 1
|
||||
: sample.customerIds.length === 0)
|
||||
}
|
||||
onClick={async () => {
|
||||
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 }));
|
||||
}}
|
||||
>
|
||||
<PlayIcon size={14} weight="fill" />
|
||||
Run Live{" "}
|
||||
{sample.mode === "limit"
|
||||
? `(${sample.limit || 0})`
|
||||
: `(${sample.customerIds.length})`}
|
||||
</ShortcutButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</StepIndicator>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -596,3 +766,127 @@ export function MigrationLiveView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-48 overflow-y-auto rounded-xl border border-border mt-1.5">
|
||||
{previewed.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-t4">
|
||||
No customers to preview
|
||||
</div>
|
||||
) : (
|
||||
previewed.map((c) => (
|
||||
<div
|
||||
key={c.internal_id}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="flex-1 truncate text-t1">
|
||||
{c.name || c.id || c.internal_id}
|
||||
</span>
|
||||
{c.email && (
|
||||
<span className="text-xs text-t4 truncate max-w-32">
|
||||
{c.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search customers..."
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="h-48 overflow-y-auto rounded-xl border border-border">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-t4">
|
||||
No customers found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((c) => {
|
||||
const isSelected = selectedIds.includes(c.id ?? c.internal_id);
|
||||
return (
|
||||
<button
|
||||
key={c.internal_id}
|
||||
type="button"
|
||||
onClick={() => toggle(c.id ?? c.internal_id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-muted/50 cursor-pointer transition-colors",
|
||||
isSelected && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 rounded border flex items-center justify-center shrink-0",
|
||||
isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckIcon size={10} className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="flex-1 truncate text-t1">
|
||||
{c.name || c.id || c.internal_id}
|
||||
</span>
|
||||
{c.email && (
|
||||
<span className="text-xs text-t4 truncate max-w-32">
|
||||
{c.email}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-t3">
|
||||
{selectedIds.length} selected
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t mt-3 pt-3 flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-t1">Add Operation</span>
|
||||
<div className="flex gap-3">
|
||||
<ActionCard
|
||||
icon={
|
||||
<PencilSimpleIcon
|
||||
size={20}
|
||||
weight="duotone"
|
||||
className="text-t3 shrink-0"
|
||||
/>
|
||||
}
|
||||
heading="Update Plan"
|
||||
subheading="Modify existing customer plans"
|
||||
<div className="border-t mt-3 pt-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
|
||||
<PlusIcon size={10} />
|
||||
Add Operation
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--anchor-width)"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() =>
|
||||
setOperations([...operations, DEFAULT_UPDATE_PLAN])
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
>
|
||||
<PencilSimpleIcon size={14} weight="duotone" />
|
||||
Update Plan
|
||||
</DropdownMenuItem>
|
||||
{!hasAddPlans && (
|
||||
<ActionCard
|
||||
icon={
|
||||
<PackageIcon
|
||||
size={20}
|
||||
weight="duotone"
|
||||
className="text-t3 shrink-0"
|
||||
/>
|
||||
}
|
||||
heading="Add Plan"
|
||||
subheading="Assign a new plan to customers"
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() =>
|
||||
setOperations([
|
||||
...operations,
|
||||
{ type: "add_plan", plan_id: "" },
|
||||
])
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
>
|
||||
<PackageIcon size={14} weight="duotone" />
|
||||
Add Plan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<number>();
|
||||
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}`,
|
||||
|
||||
Reference in New Issue
Block a user