feat: implement MirrorDestinationEditor component for customizable organization destination

This commit is contained in:
Arunavo Ray
2025-06-24 11:33:02 +05:30
parent cfe65cadca
commit ce367e3761
2 changed files with 222 additions and 113 deletions

View File

@@ -0,0 +1,193 @@
import { useState } from "react";
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, XCircle, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface MirrorDestinationEditorProps {
organizationId: string;
organizationName: string;
currentDestination?: string;
onUpdate: (newDestination: string | null) => Promise<void>;
isUpdating?: boolean;
className?: string;
}
export function MirrorDestinationEditor({
organizationId,
organizationName,
currentDestination,
onUpdate,
isUpdating = false,
className,
}: MirrorDestinationEditorProps) {
const [isOpen, setIsOpen] = useState(false);
const [editValue, setEditValue] = useState(currentDestination || "");
const [isLoading, setIsLoading] = useState(false);
const hasOverride = currentDestination && currentDestination !== organizationName;
const effectiveDestination = currentDestination || organizationName;
const handleSave = async () => {
const trimmedValue = editValue.trim();
const newDestination = trimmedValue === "" || trimmedValue === organizationName
? null
: trimmedValue;
setIsLoading(true);
try {
await onUpdate(newDestination);
setIsOpen(false);
toast.success(
newDestination
? `Destination updated to: ${newDestination}`
: "Destination reset to default"
);
} catch (error) {
toast.error("Failed to update destination");
} finally {
setIsLoading(false);
}
};
const handleReset = async () => {
setEditValue("");
await handleSave();
};
const handleCancel = () => {
setEditValue(currentDestination || "");
setIsOpen(false);
};
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Building2 className="h-3 w-3" />
<span className="font-medium">{organizationName}</span>
<ArrowRight className="h-3 w-3" />
<span className={cn(
"font-medium",
hasOverride && "text-orange-600 dark:text-orange-400"
)}>
{effectiveDestination}
</span>
{hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
custom
</Badge>
)}
</div>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
title="Edit mirror destination"
disabled={isUpdating || isLoading}
>
<Edit3 className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
<p className="text-xs text-muted-foreground">
Customize where this organization's repositories are mirrored to in Gitea.
</p>
</div>
<div className="space-y-3">
{/* Visual Preview */}
<div className="rounded-md bg-muted/50 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Preview</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{organizationName}</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-1.5">
<Building2 className="h-4 w-4 text-primary" />
<span className="font-medium text-primary">
{editValue.trim() || organizationName}
</span>
</div>
</div>
</div>
{/* Input Field */}
<div className="space-y-2">
<Label htmlFor="destination" className="text-xs">
Destination Organization
</Label>
<Input
id="destination"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={organizationName}
className="h-8"
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default GitHub organization name
</p>
</div>
{/* Quick Actions */}
{hasOverride && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isLoading}
className="w-full h-8 text-xs"
>
<RotateCcw className="h-3 w-3 mr-2" />
Reset to Default ({organizationName})
</Button>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
>
{isLoading ? (
<>
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
}

View File

@@ -1,17 +1,15 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Settings, ArrowRight, Edit3, X, Save } from "lucide-react";
import { toast } from "sonner";
import { SiGithub } from "react-icons/si"; import { SiGithub } from "react-icons/si";
import type { Organization } from "@/lib/db/schema"; import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
interface OrganizationListProps { interface OrganizationListProps {
organizations: Organization[]; organizations: Organization[];
@@ -50,60 +48,29 @@ export function OrganizationList({
onAddOrganization, onAddOrganization,
onRefresh, onRefresh,
}: OrganizationListProps) { }: OrganizationListProps) {
const [editingOrg, setEditingOrg] = useState<string | null>(null); const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
const [editValue, setEditValue] = useState<string>(""); // Call API to update organization destination
const [isUpdating, setIsUpdating] = useState<string | null>(null); const response = await fetch(`/api/organizations/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
destinationOrg: newDestination,
}),
});
const handleEditStart = (orgId: string, currentDestination?: string) => { if (!response.ok) {
setEditingOrg(orgId); const errorData = await response.json();
setEditValue(currentDestination || ""); throw new Error(errorData.error || "Failed to update organization");
}; }
const handleEditCancel = () => { // Refresh organizations data
setEditingOrg(null); if (onRefresh) {
setEditValue(""); await onRefresh();
};
const handleEditSave = async (orgId: string) => {
setIsUpdating(orgId);
try {
// Call API to update organization destination
const response = await fetch(`/api/organizations/${orgId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
destinationOrg: editValue.trim() || null,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to update organization");
}
const result = await response.json();
// Close edit mode
setEditingOrg(null);
setEditValue("");
// Show success message
const destination = result.destinationOrg || "default";
toast.success(`Organization destination updated to: ${destination}`);
// Refresh organizations data
if (onRefresh) {
await onRefresh();
}
} catch (error) {
console.error("Error updating organization:", error);
toast.error(error instanceof Error ? error.message : "Failed to update organization");
} finally {
setIsUpdating(null);
} }
}; };
const hasAnyFilter = Object.values(filter).some( const hasAnyFilter = Object.values(filter).some(
(val) => val?.toString().trim() !== "" (val) => val?.toString().trim() !== ""
); );
@@ -202,64 +169,13 @@ export function OrganizationList({
{/* Destination override section */} {/* Destination override section */}
<div className="mt-2"> <div className="mt-2">
{editingOrg === org.id ? ( <MirrorDestinationEditor
<div className="space-y-2"> organizationId={org.id!}
<Label htmlFor={`dest-${org.id}`} className="text-xs text-muted-foreground"> organizationName={org.name!}
Mirror destination (leave empty for default: {org.name}) currentDestination={org.destinationOrg}
</Label> onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
<div className="flex items-center gap-2"> isUpdating={isLoading}
<Input />
id={`dest-${org.id}`}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder={org.name}
className="h-7 text-xs"
disabled={isUpdating === org.id}
/>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditSave(org.id!)}
disabled={isUpdating === org.id}
className="h-7 w-7 p-0"
>
<Save className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleEditCancel}
disabled={isUpdating === org.id}
className="h-7 w-7 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRight className="h-3 w-3" />
<span>
Mirrors to: <span className="font-medium">
{org.destinationOrg || org.name}
</span>
{org.destinationOrg && (
<span className="text-orange-600 ml-1">(override)</span>
)}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditStart(org.id!, org.destinationOrg)}
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
title="Edit destination"
>
<Edit3 className="h-3 w-3" />
</Button>
</div>
)}
</div> </div>
</div> </div>
<Badge variant={statusBadge.variant} className="ml-2"> <Badge variant={statusBadge.variant} className="ml-2">