feat(13-03): add icon prop to BackendCard and category/search to BackendSelectionStep
- BackendCard accepts optional icon prop rendered in flex row with name
- BackendSelectionStep groups 18 backends into 3 category sections
- Search bar (type=search/searchbox role) filters across displayName, description, category label, field labels
- Empty categories hidden via conditional rendering
- BACKEND_ICONS wired to BackendCard icon prop
- Changed search input to type=search to preserve getByRole('textbox') selector in existing tests
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface BackendCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||
name: string;
|
||||
description: string;
|
||||
selected?: boolean;
|
||||
onClick: () => void;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function BackendCard({ name, description, selected = false, onClick, ...rest }: BackendCardProps) {
|
||||
export function BackendCard({ name, description, selected = false, onClick, icon, ...rest }: BackendCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -21,7 +22,14 @@ export function BackendCard({ name, description, selected = false, onClick, ...r
|
||||
].join(' ')}
|
||||
{...rest}
|
||||
>
|
||||
{icon != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 shrink-0">{icon}</span>
|
||||
<span className="font-semibold text-on-surface">{name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-semibold text-on-surface">{name}</span>
|
||||
)}
|
||||
<span className="text-sm text-on-surface-container/70">{description}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import React from 'react';
|
||||
import { useWizard } from '../../store/context';
|
||||
import type { BackendType } from '../../store/types';
|
||||
import type { BackendType, BackendCategory } from '../../schemas/registry';
|
||||
import { BACKEND_REGISTRY } from '../../schemas/registry';
|
||||
import { BackendCard } from '../ui/BackendCard';
|
||||
import { MD3_BTN_FILLED } from '../../styles/md3-buttons';
|
||||
import { TextFieldMD3 } from '../ui/TextFieldMD3';
|
||||
import { RemoteNamePreview } from '../ui/RemoteNamePreview';
|
||||
import { BACKEND_ICONS } from '../icons/BackendIcons';
|
||||
|
||||
const remoteNameSchema = z.object({
|
||||
name: z
|
||||
@@ -22,10 +24,30 @@ const remoteNameSchema = z.object({
|
||||
|
||||
type RemoteNameFormValues = z.infer<typeof remoteNameSchema>;
|
||||
|
||||
const CATEGORY_ORDER: BackendCategory[] = ['cloud-object-storage', 'cloud-drives', 'protocol-based'];
|
||||
const CATEGORY_LABELS: Record<BackendCategory, string> = {
|
||||
'cloud-object-storage': 'Cloud Object Storage',
|
||||
'cloud-drives': 'Cloud Drives',
|
||||
'protocol-based': 'Protocol-based',
|
||||
};
|
||||
|
||||
function matchesSearch(
|
||||
entry: { displayName: string; description: string; category: BackendCategory; fields: { label: string }[] },
|
||||
query: string
|
||||
): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
entry.displayName.toLowerCase().includes(q) ||
|
||||
entry.description.toLowerCase().includes(q) ||
|
||||
CATEGORY_LABELS[entry.category].toLowerCase().includes(q) ||
|
||||
entry.fields.some(f => f.label.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
export function BackendSelectionStep() {
|
||||
const { state, dispatch } = useWizard();
|
||||
const pendingBackend = useRef<BackendType | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -77,17 +99,38 @@ export function BackendSelectionStep() {
|
||||
helpText="This becomes the section header [name] in your rclone.conf. Example: azure-prod, backup-s3. Letters, numbers, dashes, underscores only."
|
||||
/>
|
||||
<RemoteNamePreview value={remoteName} />
|
||||
<div data-testid="backend-cards" className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
|
||||
{Object.entries(BACKEND_REGISTRY).map(([type, entry]) => (
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search backends..."
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-outline bg-surface-container text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary mt-4"
|
||||
/>
|
||||
<div data-testid="backend-cards" className="mt-4">
|
||||
{CATEGORY_ORDER.map(cat => {
|
||||
const backends = Object.entries(BACKEND_REGISTRY)
|
||||
.filter(([, e]) => e.category === cat)
|
||||
.filter(([, e]) => !searchQuery || matchesSearch(e, searchQuery));
|
||||
if (backends.length === 0) return null;
|
||||
return (
|
||||
<section key={cat} className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-on-surface mb-3">{CATEGORY_LABELS[cat]}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{backends.map(([type, entry]) => (
|
||||
<BackendCard
|
||||
key={type}
|
||||
name={entry.displayName}
|
||||
description={entry.description}
|
||||
selected={state.remote.backendType === type}
|
||||
onClick={() => handleCardClick(type as BackendType)}
|
||||
icon={BACKEND_ICONS[type as BackendType] ? React.createElement(BACKEND_ICONS[type as BackendType]!, { className: 'w-6 h-6' }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-6">
|
||||
<button type="submit" className={`${MD3_BTN_FILLED} w-full sm:w-auto`}>
|
||||
Next
|
||||
|
||||
Reference in New Issue
Block a user