From 3a67dbd75d429aff24e21272c8fa735a89520b35 Mon Sep 17 00:00:00 2001 From: Luann Moreira Date: Tue, 26 May 2026 16:05:00 -0300 Subject: [PATCH] refactor(ui-react): decompose DeviceDetails into focused sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeviceDetails.tsx was 816 lines, mixing device info display, inline tag CRUD, inline rename, inline custom fields, and an inline delete modal — all in one file. Extract four sub-components: - InfoItem: reusable
/
display row with optional copy button - TagsSection: tag CRUD (max 3, alphanumeric, 3–255 chars), with aria-label on add/remove buttons - RenameSection: inline rename with Enter=save / Escape=cancel - CustomFieldsSection: key-value pairs with inline confirm-to-delete Replace the inline delete modal with ConfirmDialog (with error state). DeviceDetails drops from 816 → 444 lines. --- .../apps/console/src/pages/DeviceDetails.tsx | 461 ++---------------- .../src/pages/devices/CustomFieldsSection.tsx | 163 +++++++ .../console/src/pages/devices/InfoItem.tsx | 36 ++ .../src/pages/devices/RenameSection.tsx | 103 ++++ .../console/src/pages/devices/TagsSection.tsx | 126 +++++ 5 files changed, 463 insertions(+), 426 deletions(-) create mode 100644 ui-react/apps/console/src/pages/devices/CustomFieldsSection.tsx create mode 100644 ui-react/apps/console/src/pages/devices/InfoItem.tsx create mode 100644 ui-react/apps/console/src/pages/devices/RenameSection.tsx create mode 100644 ui-react/apps/console/src/pages/devices/TagsSection.tsx diff --git a/ui-react/apps/console/src/pages/DeviceDetails.tsx b/ui-react/apps/console/src/pages/DeviceDetails.tsx index 46c1f1e3789..0844b5691fb 100644 --- a/ui-react/apps/console/src/pages/DeviceDetails.tsx +++ b/ui-react/apps/console/src/pages/DeviceDetails.tsx @@ -2,11 +2,6 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import Breadcrumb from "@/components/common/Breadcrumb"; import { - TagIcon, - XMarkIcon, - PlusIcon, - PencilSquareIcon, - CheckIcon, TrashIcon, InformationCircleIcon, ComputerDesktopIcon, @@ -15,407 +10,31 @@ import { ChevronDoubleRightIcon, } from "@heroicons/react/24/outline"; import { useDevice } from "../hooks/useDevice"; -import { - useRenameDevice, - useAddDeviceTag, - useRemoveDeviceTag, - useRemoveDevice, - useSetDeviceCustomField, - useDeleteDeviceCustomField, -} from "../hooks/useDeviceMutations"; +import { useRemoveDevice } from "../hooks/useDeviceMutations"; import { useNamespace } from "../hooks/useNamespaces"; import { useAuthStore } from "../stores/authStore"; import { useTerminalStore } from "../stores/terminalStore"; import DeviceActionDialog from "./devices/DeviceActionDialog"; import BillingWarning from "../components/billing/BillingWarning"; import ConnectDrawer from "../components/ConnectDrawer"; +import ConfirmDialog from "../components/common/ConfirmDialog"; import CopyButton from "../components/common/CopyButton"; import PlatformBadge from "../components/common/PlatformBadge"; import { formatDateFull, formatRelative } from "../utils/date"; import { buildSshid } from "../utils/sshid"; -import { useHasPermission } from "../hooks/useHasPermission"; import RestrictedAction from "../components/common/RestrictedAction"; import { getConfig } from "../env"; import PageLoader from "@/components/common/PageLoader"; +import InfoItem from "./devices/InfoItem"; +import TagsSection from "./devices/TagsSection"; +import RenameSection from "./devices/RenameSection"; +import CustomFieldsSection from "./devices/CustomFieldsSection"; /* ─── Shared styles ─── */ const LABEL = "text-2xs font-mono font-semibold uppercase tracking-label text-text-muted"; const VALUE = "text-sm text-text-primary font-medium mt-0.5"; -/* ─── Info Row ─── */ -function InfoItem({ - label, - value, - mono, - copyable, - truncate, -}: { - label: string; - value: string; - mono?: boolean; - copyable?: boolean; - truncate?: number; -}) { - const display = truncate && value ? value.slice(0, truncate) : value; - - return ( -
-
{label}
-
- - {display || "—"} - - {copyable && value && } -
- - ); -} - -/* ─── Tags Section ─── */ -function TagsSection({ uid, tags }: { uid: string; tags: string[] }) { - const addTagMutation = useAddDeviceTag(); - const removeTagMutation = useRemoveDeviceTag(); - const canEditTags = useHasPermission("tag:edit"); - const [input, setInput] = useState(""); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - - const handleAdd = async () => { - const tag = input.trim(); - if (!tag) return; - setError(null); - - if (tags && tags.includes(tag)) { - setError("This tag is already added."); - return; - } - if (tags && tags.length >= 3) return; - if (tag.length < 3) { - setError("Tag must be at least 3 characters."); - return; - } - if (tag.length > 255) { - setError("Tag must be at most 255 characters."); - return; - } - if (!/^[a-zA-Z0-9]+$/.test(tag)) { - setError("Tag must contain only letters and numbers."); - return; - } - - setAdding(true); - try { - await addTagMutation.mutateAsync({ path: { uid, name: tag } }); - setInput(""); - } catch { - setError("Failed to add tag."); - } - setAdding(false); - }; - - const handleRemove = async (tag: string) => { - try { - await removeTagMutation.mutateAsync({ path: { uid, name: tag } }); - } catch { - /* invalidation handles UI update */ - } - }; - - return ( -
-

Tags

-
- {tags && - tags.map((tag) => ( - - - {tag} - {canEditTags && ( - - )} - - ))} - {canEditTags && (!tags || tags.length < 3) && ( -
- { - setInput(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleAdd(); - } - }} - placeholder="Add tag..." - pattern="^[a-zA-Z0-9]+$" - className="w-28 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" - /> - -
- )} -
- {tags && tags.length >= 3 && ( -

- Maximum of 3 tags reached. -

- )} - {error &&

{error}

} -
- ); -} - -/* ─── Rename Inline ─── */ -function RenameSection({ - uid, - currentName, -}: { - uid: string; - currentName: string; -}) { - const renameMutation = useRenameDevice(); - const canRename = useHasPermission("device:rename"); - const [editing, setEditing] = useState(false); - const [name, setName] = useState(currentName); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const handleSave = async () => { - if (!name.trim() || name.trim() === currentName) { - setEditing(false); - return; - } - setSaving(true); - setError(null); - try { - await renameMutation.mutateAsync({ - path: { uid }, - body: { name: name.trim() }, - }); - setEditing(false); - } catch { - setError("Failed to rename device."); - } - setSaving(false); - }; - - if (!editing) { - return ( -
-

{currentName}

- {canRename && ( - - )} -
- ); - } - - return ( -
-
- setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") void handleSave(); - if (e.key === "Escape") setEditing(false); - }} - autoFocus - className="text-2xl font-bold text-text-primary bg-transparent border-b-2 border-primary/50 focus:outline-none focus:border-primary w-full max-w-md" - /> - - -
- {error &&

{error}

} -
- ); -} - -/* ─── Custom Fields Section ─── */ -function CustomFieldsSection({ - uid, - customFields, -}: { - uid: string; - customFields: Record; -}) { - const setMutation = useSetDeviceCustomField(); - const deleteMutation = useDeleteDeviceCustomField(); - const canEdit = useHasPermission("device:customField:update"); - const [keyInput, setKeyInput] = useState(""); - const [valueInput, setValueInput] = useState(""); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - const [confirmKey, setConfirmKey] = useState(null); - - const handleAdd = async () => { - const key = keyInput.trim(); - const value = valueInput.trim(); - if (!key || !value) return; - if (key in customFields) { - setError("This key already exists."); - return; - } - setError(null); - setAdding(true); - try { - await setMutation.mutateAsync({ - path: { uid, key }, - body: { value }, - }); - setKeyInput(""); - setValueInput(""); - } catch { - setError("Failed to add custom field."); - } - setAdding(false); - }; - - const handleRemove = async (key: string) => { - try { - await deleteMutation.mutateAsync({ - path: { uid, key }, - }); - } catch { - /* invalidation handles UI update */ - } - }; - - return ( -
-

Custom Fields

-
- {Object.entries(customFields).map(([key, value]) => ( -
-
- - {key}: - - - {value} - -
- {canEdit && - (confirmKey === key ? ( -
- Remove? - - -
- ) : ( - - ))} -
- ))} -
- {canEdit && ( -
- { - setKeyInput(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleAdd(); - } - }} - placeholder="key" - className="w-24 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" - /> - : - { - setValueInput(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleAdd(); - } - }} - placeholder="value" - className="w-32 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" - /> - -
- )} - {error &&

{error}

} -
- ); -} - /* ─── Page ─── */ export default function DeviceDetails() { const { uid } = useParams<{ uid: string }>(); @@ -431,7 +50,7 @@ export default function DeviceDetails() { const restoreTerminal = useTerminalStore((s) => s.restore); const [connectOpen, setConnectOpen] = useState(false); const [showDelete, setShowDelete] = useState(false); - const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const [operation, setOperation] = useState<{ device: { uid: string; name: string }; action: "accept" | "reject" | "remove"; @@ -480,19 +99,18 @@ export default function DeviceDetails() { : []; const handleDelete = async () => { - setDeleting(true); + setDeleteError(null); try { await removeMutation.mutateAsync({ path: { uid: device.uid } }); setShowDelete(false); void navigate("/devices"); } catch { - setDeleting(false); + setDeleteError("Failed to delete device. Please try again."); } }; const handleDeviceActionSuccess = () => { if (!operation) return; - if (operation.action === "remove") void navigate("/devices"); }; @@ -555,6 +173,7 @@ export default function DeviceDetails() { <> - - - - - )} + { + setShowDelete(false); + setDeleteError(null); + }} + onConfirm={handleDelete} + title="Delete Device" + description={ + <> + Are you sure you want to delete{" "} + {device.name} + ? This action cannot be undone. + + } + confirmLabel="Delete" + variant="danger" + errorMessage={deleteError} + /> {/* Connect Drawer */} ; +} + +export default function CustomFieldsSection({ + uid, + customFields, +}: CustomFieldsSectionProps) { + const setMutation = useSetDeviceCustomField(); + const deleteMutation = useDeleteDeviceCustomField(); + const canEdit = useHasPermission("device:customField:update"); + const [keyInput, setKeyInput] = useState(""); + const [valueInput, setValueInput] = useState(""); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + const [confirmKey, setConfirmKey] = useState(null); + + const handleAdd = async () => { + const key = keyInput.trim(); + const value = valueInput.trim(); + if (!key || !value) return; + if (key in customFields) { + setError("This key already exists."); + return; + } + setError(null); + setAdding(true); + try { + await setMutation.mutateAsync({ + path: { uid, key }, + body: { value }, + }); + setKeyInput(""); + setValueInput(""); + } catch { + setError("Failed to add custom field."); + } + setAdding(false); + }; + + const handleRemove = async (key: string) => { + try { + await deleteMutation.mutateAsync({ + path: { uid, key }, + }); + } catch { + /* invalidation handles UI update */ + } + }; + + return ( +
+

Custom Fields

+
+ {Object.entries(customFields).map(([key, value]) => ( +
+
+ + {key}: + + + {value} + +
+ {canEdit && + (confirmKey === key ? ( +
+ Remove? + + +
+ ) : ( + + ))} +
+ ))} +
+ {canEdit && ( +
+ { + setKeyInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAdd(); + } + }} + placeholder="key" + aria-label="Custom field key" + className="w-24 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + : + { + setValueInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAdd(); + } + }} + placeholder="value" + aria-label="Custom field value" + className="w-32 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + +
+ )} + {error &&

{error}

} +
+ ); +} diff --git a/ui-react/apps/console/src/pages/devices/InfoItem.tsx b/ui-react/apps/console/src/pages/devices/InfoItem.tsx new file mode 100644 index 00000000000..da9374cb8b7 --- /dev/null +++ b/ui-react/apps/console/src/pages/devices/InfoItem.tsx @@ -0,0 +1,36 @@ +import CopyButton from "@/components/common/CopyButton"; + +const LABEL = + "text-2xs font-mono font-semibold uppercase tracking-label text-text-muted"; + +interface InfoItemProps { + label: string; + value: string; + mono?: boolean; + copyable?: boolean; + truncate?: number; +} + +export default function InfoItem({ + label, + value, + mono, + copyable, + truncate, +}: InfoItemProps) { + const display = truncate && value ? value.slice(0, truncate) : value; + + return ( +
+
{label}
+
+ + {display || "—"} + + {copyable && value && } +
+
+ ); +} diff --git a/ui-react/apps/console/src/pages/devices/RenameSection.tsx b/ui-react/apps/console/src/pages/devices/RenameSection.tsx new file mode 100644 index 00000000000..638c25d6b5b --- /dev/null +++ b/ui-react/apps/console/src/pages/devices/RenameSection.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { + PencilSquareIcon, + CheckIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { useRenameDevice } from "@/hooks/useDeviceMutations"; +import { useHasPermission } from "@/hooks/useHasPermission"; + +interface RenameSectionProps { + uid: string; + currentName: string; +} + +export default function RenameSection({ + uid, + currentName, +}: RenameSectionProps) { + const renameMutation = useRenameDevice(); + const canRename = useHasPermission("device:rename"); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(currentName); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async () => { + if (!name.trim() || name.trim() === currentName) { + setEditing(false); + return; + } + setSaving(true); + setError(null); + try { + await renameMutation.mutateAsync({ + path: { uid }, + body: { name: name.trim() }, + }); + setEditing(false); + } catch { + setError("Failed to rename device."); + } + setSaving(false); + }; + + if (!editing) { + return ( +
+

{currentName}

+ {canRename && ( + + )} +
+ ); + } + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + if (e.key === "Escape") setEditing(false); + }} + aria-label="Device name" + autoFocus + className="text-2xl font-bold text-text-primary bg-transparent border-b-2 border-primary/50 focus:outline-none focus:border-primary w-full max-w-md" + /> + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/ui-react/apps/console/src/pages/devices/TagsSection.tsx b/ui-react/apps/console/src/pages/devices/TagsSection.tsx new file mode 100644 index 00000000000..1e9b49213b7 --- /dev/null +++ b/ui-react/apps/console/src/pages/devices/TagsSection.tsx @@ -0,0 +1,126 @@ +import { useState } from "react"; +import { TagIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { useAddDeviceTag, useRemoveDeviceTag } from "@/hooks/useDeviceMutations"; +import { useHasPermission } from "@/hooks/useHasPermission"; + +const LABEL = + "text-2xs font-mono font-semibold uppercase tracking-label text-text-muted"; + +interface TagsSectionProps { + uid: string; + tags: string[]; +} + +export default function TagsSection({ uid, tags }: TagsSectionProps) { + const addTagMutation = useAddDeviceTag(); + const removeTagMutation = useRemoveDeviceTag(); + const canEditTags = useHasPermission("tag:edit"); + const [input, setInput] = useState(""); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + + const handleAdd = async () => { + const tag = input.trim(); + if (!tag) return; + setError(null); + + if (tags && tags.includes(tag)) { + setError("This tag is already added."); + return; + } + if (tags && tags.length >= 3) return; + if (tag.length < 3) { + setError("Tag must be at least 3 characters."); + return; + } + if (tag.length > 255) { + setError("Tag must be at most 255 characters."); + return; + } + if (!/^[a-zA-Z0-9]+$/.test(tag)) { + setError("Tag must contain only letters and numbers."); + return; + } + + setAdding(true); + try { + await addTagMutation.mutateAsync({ path: { uid, name: tag } }); + setInput(""); + } catch { + setError("Failed to add tag."); + } + setAdding(false); + }; + + const handleRemove = async (tag: string) => { + try { + await removeTagMutation.mutateAsync({ path: { uid, name: tag } }); + } catch { + /* invalidation handles UI update */ + } + }; + + return ( +
+

Tags

+
+ {tags && + tags.map((tag) => ( + + + {tag} + {canEditTags && ( + + )} + + ))} + {canEditTags && (!tags || tags.length < 3) && ( +
+ { + setInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAdd(); + } + }} + placeholder="Add tag..." + aria-label="New tag" + className="w-28 px-2.5 py-1 bg-card border border-border rounded-md text-xs text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-primary/40 transition-all" + /> + +
+ )} +
+ {tags && tags.length >= 3 && ( +

+ Maximum of 3 tags reached. +

+ )} + {error &&

{error}

} +
+ ); +}