feat: change node tag dialog to use Select

This commit is contained in:
Meow
2025-11-06 19:49:55 -05:00
parent 1aa921aebe
commit c3e07e4e10
6 changed files with 86 additions and 60 deletions
+14 -14
View File
@@ -50,11 +50,11 @@ function Select(props: SelectProps) {
<div className={cn('flex flex-col', props.className)}> <div className={cn('flex flex-col', props.className)}>
<label <label
{...labelProps} {...labelProps}
htmlFor={id}
className={cn( className={cn(
'text-xs font-medium px-3 mb-0.5', 'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100', 'text-headplane-700 dark:text-headplane-100',
)} )}
htmlFor={id}
> >
{props.label} {props.label}
</label> </label>
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
'flex rounded-xl focus:outline-hidden focus-within:ring-3', 'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)} )}
> >
<input <input
{...inputProps} {...inputProps}
ref={inputRef}
id={id}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent" className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore data-1p-ignore
id={id}
ref={inputRef}
/> />
<button <button
{...buttonProps} {...buttonProps}
ref={buttonRef}
className={cn( className={cn(
'flex items-center justify-center p-1 rounded-lg m-1', 'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium', 'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
? 'opacity-50 cursor-not-allowed' ? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30', : 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)} )}
ref={buttonRef}
> >
<ChevronDown className="p-0.5" /> <ChevronDown className="p-0.5" />
</button> </button>
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
)} )}
{state.isOpen && ( {state.isOpen && (
<Popover <Popover
popoverRef={popoverRef} className="w-full max-w-xs"
triggerRef={inputRef}
state={state}
isNonModal isNonModal
placement="bottom start" placement="bottom start"
className="w-full max-w-xs" popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
> >
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} /> <ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover> </Popover>
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
} }
interface ListBoxProps extends AriaListBoxOptions<object> { interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>; listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>; state: ListState<object>;
} }
function ListBox(props: ListBoxProps) { function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props; const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null); const { listBoxProps } = useListBox(props, state, listBoxRef);
const { listBoxProps } = useListBox(props, state, ref);
return ( return (
<ul <ul
{...listBoxProps} {...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-hidden pt-1" className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
> >
{[...state.collection].map((item) => ( {[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} /> <Option item={item} key={item.key} state={state} />
))} ))}
</ul> </ul>
); );
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
return ( return (
<li <li
{...optionProps} {...optionProps}
ref={ref}
className={cn( className={cn(
'flex items-center justify-between', 'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1', 'py-2 px-3 mx-1 rounded-lg mb-1',
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800', : 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600', isDisabled && 'text-headplane-300 dark:text-headplane-600',
)} )}
ref={ref}
> >
{item.rendered} {item.rendered}
{isSelected && <Check className="p-0.5" />} {isSelected && <Check className="p-0.5" />}
@@ -9,7 +9,7 @@ import { ExpiryTag } from '~/components/tags/Expiry';
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
import { SubnetTag } from '~/components/tags/Subnet'; import { SubnetTag } from '~/components/tags/Subnet';
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH'; import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
import type { User } from '~/types'; import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info'; import * as hinfo from '~/utils/host-info';
import { PopulatedNode } from '~/utils/node-info'; import { PopulatedNode } from '~/utils/node-info';
@@ -22,6 +22,7 @@ interface Props {
isAgent?: boolean; isAgent?: boolean;
magic?: string; magic?: string;
isDisabled?: boolean; isDisabled?: boolean;
nodeList?: Machine[];
} }
export default function MachineRow({ export default function MachineRow({
@@ -30,6 +31,7 @@ export default function MachineRow({
isAgent, isAgent,
magic, magic,
isDisabled, isDisabled,
nodeList,
}: Props) { }: Props) {
const uiTags = useMemo(() => { const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent); const tags = uiTagsForNode(node, isAgent);
@@ -149,6 +151,7 @@ export default function MachineRow({
isDisabled={isDisabled} isDisabled={isDisabled}
magic={magic} magic={magic}
node={node} node={node}
nodeList={nodeList}
users={users} users={users}
/> />
</td> </td>
+4 -1
View File
@@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import type { User } from '~/types'; import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info'; import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete'; import Delete from '../dialogs/delete';
@@ -18,6 +18,7 @@ interface MenuProps {
magic?: string; magic?: string;
isFullButton?: boolean; isFullButton?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
nodeList?: Machine[];
} }
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@@ -28,6 +29,7 @@ export default function MachineMenu({
users, users,
isFullButton, isFullButton,
isDisabled, isDisabled,
nodeList,
}: MenuProps) { }: MenuProps) {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH = const supportsTailscaleSSH =
@@ -77,6 +79,7 @@ export default function MachineMenu({
<Tags <Tags
isOpen={modal === 'tags'} isOpen={modal === 'tags'}
machine={node} machine={node}
nodeList={nodeList}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
+52 -41
View File
@@ -1,9 +1,9 @@
import { Plus, TagsIcon, X } from 'lucide-react'; import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import type { Machine } from '~/types'; import type { Machine } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@@ -12,11 +12,30 @@ interface TagsProps {
machine: Machine; machine: Machine;
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
nodeList?: Machine[];
} }
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) { export default function Tags({
machine,
isOpen,
setIsOpen,
nodeList,
}: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags); const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState(''); const [tag, setTag] = useState('tag:');
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
}, [tag, tags]);
const validNodeTags = useMemo(() => {
if (!nodeList?.length) return [];
const allNodeTags = Array.from(
new Set(
nodeList.flatMap((node) => [...node.validTags, ...node.forcedTags]),
),
).sort();
return allNodeTags.filter((nodeTag) => !tags.includes(nodeTag));
}, [tags]);
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
@@ -57,44 +76,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</TableList.Item> </TableList.Item>
)) ))
)} )}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring-3',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring-3 ring-red-500 ring-opacity-50',
)}
>
<Input
className={cn(
'border-none font-mono p-0',
'rounded-none focus:ring-0 w-full',
)}
label="Add a tag"
labelHidden
onChange={setTag}
placeholder="tag:example"
/>
<Button
className={cn(
'rounded-md p-0.5',
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<Plus className="p-1" />
</Button>
</TableList.Item>
</TableList> </TableList>
<div className="flex items-center gap-2 mt-2">
<Select
allowsCustomValue
aria-label="Add a tag"
className="w-full"
inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
</Select>
<Button
className={cn(
'rounded-md p-1',
tagIsInvalid && 'opacity-50 cursor-not-allowed',
)}
isDisabled={tagIsInvalid}
onPress={() => {
setTags([...tags, tag]);
setTag('tag:');
}}
>
<Plus className="p-1" size={30} />
</Button>
</div>
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );
+11 -3
View File
@@ -35,9 +35,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const [node, users] = await Promise.all([ const [node, users, allNodes] = await Promise.all([
api.getNode(params.id), api.getNode(params.id),
api.getUsers(), api.getUsers(),
api.getNodes(),
]); ]);
const lookup = await context.agents?.lookup([node.nodeKey]); const lookup = await context.agents?.lookup([node.nodeKey]);
@@ -53,13 +54,14 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
magic, magic,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey], stats: lookup?.[enhancedNode.nodeKey],
nodeList: allNodes,
}; };
} }
export const action = machineAction; export const action = machineAction;
export default function Page({ export default function Page({
loaderData: { node, tags, users, magic, agent, stats }, loaderData: { node, tags, users, magic, agent, stats, nodeList },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false); const [showRouting, setShowRouting] = useState(false);
@@ -87,7 +89,13 @@ export default function Page({
<h1 className="text-2xl font-medium">{node.givenName}</h1> <h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} /> <StatusCircle className="w-4 h-4" isOnline={node.online} />
</span> </span>
<MenuOptions isFullButton magic={magic} node={node} users={users} /> <MenuOptions
isFullButton
magic={magic}
node={node}
nodeList={nodeList}
users={users}
/>
</div> </div>
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4"> <div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
+1
View File
@@ -138,6 +138,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
key={node.id} key={node.id}
magic={loaderData.magic} magic={loaderData.magic}
node={node} node={node}
nodeList={loaderData.nodes}
users={loaderData.users} users={loaderData.users}
/> />
))} ))}