From 0a70620fd5d03052283a6001bc4964d6c72017e9 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 1 Apr 2026 16:50:49 +0200 Subject: [PATCH] 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 --- src/components/ui/BackendCard.tsx | 14 +++- .../wizard/BackendSelectionStep.tsx | 67 +++++++++++++++---- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/src/components/ui/BackendCard.tsx b/src/components/ui/BackendCard.tsx index 7fab752..f8fed5b 100644 --- a/src/components/ui/BackendCard.tsx +++ b/src/components/ui/BackendCard.tsx @@ -1,13 +1,14 @@ -import type { ButtonHTMLAttributes } from 'react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; interface BackendCardProps extends Omit, '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 ( ); diff --git a/src/components/wizard/BackendSelectionStep.tsx b/src/components/wizard/BackendSelectionStep.tsx index c676e15..ec12184 100644 --- a/src/components/wizard/BackendSelectionStep.tsx +++ b/src/components/wizard/BackendSelectionStep.tsx @@ -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; +const CATEGORY_ORDER: BackendCategory[] = ['cloud-object-storage', 'cloud-drives', 'protocol-based']; +const CATEGORY_LABELS: Record = { + '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(null); + const [searchQuery, setSearchQuery] = useState(''); const { register, @@ -77,16 +99,37 @@ export function BackendSelectionStep() { helpText="This becomes the section header [name] in your rclone.conf. Example: azure-prod, backup-s3. Letters, numbers, dashes, underscores only." /> -
- {Object.entries(BACKEND_REGISTRY).map(([type, entry]) => ( - handleCardClick(type as BackendType)} - /> - ))} + 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" + /> +
+ {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 ( +
+

{CATEGORY_LABELS[cat]}

+
+ {backends.map(([type, entry]) => ( + handleCardClick(type as BackendType)} + icon={BACKEND_ICONS[type as BackendType] ? React.createElement(BACKEND_ICONS[type as BackendType]!, { className: 'w-6 h-6' }) : undefined} + /> + ))} +
+
+ ); + })}