mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 01:09:25 +00:00
feat(ui): structured editor for ACL rules, tags and groups (#608)
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Input from "~/components/input";
|
||||
import TableList from "~/components/table-list";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface TokenListProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
suggestions?: string[];
|
||||
placeholder?: string;
|
||||
emptyText: string;
|
||||
isDisabled?: boolean;
|
||||
validate?: (value: string) => boolean;
|
||||
// Rewrites a value before it is added, e.g. appending a default port.
|
||||
normalize?: (value: string) => string;
|
||||
}
|
||||
|
||||
// A small chip editor shared by the ACL and user dialogs, modelled on the
|
||||
// machine tag dialog: current values, a field to add one, and suggestions.
|
||||
export default function TokenList({
|
||||
label,
|
||||
description,
|
||||
values,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder,
|
||||
emptyText,
|
||||
isDisabled,
|
||||
validate,
|
||||
normalize,
|
||||
}: TokenListProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const prepare = useMemo(
|
||||
() => (value: string) => (normalize ? normalize(value.trim()) : value.trim()),
|
||||
[normalize],
|
||||
);
|
||||
|
||||
// Suggestions are compared in their normalized form, otherwise picking
|
||||
// `tag:web` after it was added as `tag:web:*` would duplicate it.
|
||||
const available = useMemo(
|
||||
() => (suggestions ?? []).filter((suggestion) => !values.includes(prepare(suggestion))),
|
||||
[suggestions, values, prepare],
|
||||
);
|
||||
|
||||
const draftIsInvalid = useMemo(() => {
|
||||
const prepared = prepare(draft);
|
||||
if (prepared.length === 0) return true;
|
||||
if (values.includes(prepared)) return true;
|
||||
return validate ? !validate(prepared) : false;
|
||||
}, [draft, values, validate, prepare]);
|
||||
|
||||
function add(value: string) {
|
||||
const prepared = prepare(value);
|
||||
if (prepared.length === 0 || values.includes(prepared)) {
|
||||
return;
|
||||
}
|
||||
onChange([...values, prepared]);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-mist-700 dark:text-mist-200">{label}</p>
|
||||
{description ? (
|
||||
<p className="text-xs text-mist-500 dark:text-mist-400">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<TableList>
|
||||
{values.length === 0 ? (
|
||||
<TableList.Item className="justify-center py-3 text-sm opacity-70">
|
||||
{emptyText}
|
||||
</TableList.Item>
|
||||
) : (
|
||||
values.map((value) => (
|
||||
<TableList.Item className="font-mono text-sm" id={value} key={value}>
|
||||
{value}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onChange(values.filter((entry) => entry !== value))}
|
||||
type="button"
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={`Add to ${label}`}
|
||||
className="w-full"
|
||||
disabled={isDisabled}
|
||||
invalid={draft.length > 0 && draftIsInvalid}
|
||||
label={label}
|
||||
labelHidden
|
||||
onChange={setDraft}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (!draftIsInvalid) add(draft);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
className={cn("rounded-md p-1", draftIsInvalid && "cursor-not-allowed opacity-50")}
|
||||
disabled={isDisabled || draftIsInvalid}
|
||||
onClick={() => add(draft)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
{available.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((suggestion) => (
|
||||
<Button
|
||||
className="px-2 py-1 font-mono text-xs"
|
||||
disabled={isDisabled}
|
||||
key={suggestion}
|
||||
onClick={() => add(suggestion)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user