Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions packages/react/src/plugins/headers-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useState, type ReactNode } from "react";
import { PlusIcon } from "lucide-react";

import { Button } from "../components/button";
import {
CardStack,
CardStackContent,
CardStackEmpty,
CardStackEntry,
} from "../components/card-stack";
import {
defaultHeaderAuthPresets,
type HeaderAuthPreset,
type HeaderState,
SecretHeaderAuthRow,
} from "./secret-header-auth";
import type { SecretPickerSecret } from "./secret-picker";

export interface HeadersListProps {
readonly headers: readonly HeaderState[];
readonly onHeadersChange: (headers: HeaderState[]) => void;
readonly existingSecrets?: readonly SecretPickerSecret[];
/** Presets offered in the quick-add picker. Defaults to `defaultHeaderAuthPresets`. */
readonly presets?: readonly HeaderAuthPreset[];
/** When true, only allow a single header (hide add button, disable remove). */
readonly singleHeader?: boolean;
/** Text shown in the empty state. */
readonly emptyLabel?: ReactNode;
}

export function HeadersList({
headers,
onHeadersChange,
existingSecrets = [],
presets = defaultHeaderAuthPresets,
singleHeader = false,
emptyLabel = "No headers",
}: HeadersListProps) {
const [picking, setPicking] = useState(false);
const canAddMore = !singleHeader || headers.length === 0;

const addHeaderFromPreset = (preset: HeaderAuthPreset) => {
onHeadersChange([
...headers,
{
name: preset.name,
prefix: preset.prefix,
presetKey: preset.key,
secretId: null,
},
]);
setPicking(false);
};

const updateHeader = (
index: number,
update: Partial<{
name: string;
secretId: string | null;
prefix?: string;
presetKey?: string;
}>,
) => {
onHeadersChange(
headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)),
);
};

const removeHeader = (index: number) => {
onHeadersChange(headers.filter((_, i) => i !== index));
};

return (
<CardStack>
<CardStackContent className="[&>*+*]:before:inset-x-0">
{picking ? (
<HeaderPresetPicker
presets={presets}
onPick={addHeaderFromPreset}
onCancel={() => setPicking(false)}
/>
) : headers.length === 0 ? (
canAddMore ? (
<AddHeaderRow leading={<span>{emptyLabel}</span>} onClick={() => setPicking(true)} />
) : (
<CardStackEmpty>
<span>{emptyLabel}</span>
</CardStackEmpty>
)
) : (
<>
{headers.map((header, index) => (
<SecretHeaderAuthRow
key={index}
name={header.name}
prefix={header.prefix}
presetKey={header.presetKey}
secretId={header.secretId}
onChange={(update) => updateHeader(index, update)}
onSelectSecret={(secretId) => updateHeader(index, { secretId })}
onRemove={singleHeader ? undefined : () => removeHeader(index)}
existingSecrets={existingSecrets}
/>
))}
{canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />}
</>
)}
</CardStackContent>
</CardStack>
);
}

interface AddHeaderRowProps {
readonly onClick: () => void;
readonly leading?: ReactNode;
}

function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) {
return (
// oxlint-disable-next-line react/forbid-elements
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onClick();
}}
aria-label="Add header"
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40"
>
<span className="min-w-0 flex-1 text-left">{leading}</span>
<PlusIcon aria-hidden className="size-4 shrink-0" />
</button>
);
}

interface HeaderPresetPickerProps {
readonly presets: readonly HeaderAuthPreset[];
readonly onPick: (preset: HeaderAuthPreset) => void;
readonly onCancel: () => void;
}

function HeaderPresetPicker({ presets, onPick, onCancel }: HeaderPresetPickerProps) {
return (
<CardStackEntry className="flex-wrap gap-2">
{presets.map((preset) => (
<Button
key={preset.key}
type="button"
variant="outline"
size="sm"
onClick={() => onPick(preset)}
>
{preset.label}
</Button>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
className="text-muted-foreground"
>
Cancel
</Button>
</CardStackEntry>
);
}
Loading
Loading