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'> {
|
interface BackendCardProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick: () => void;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -21,7 +22,14 @@ export function BackendCard({ name, description, selected = false, onClick, ...r
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-on-surface">{name}</span>
|
{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>
|
<span className="text-sm text-on-surface-container/70">{description}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import React from 'react';
|
||||||
import { useWizard } from '../../store/context';
|
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 { BACKEND_REGISTRY } from '../../schemas/registry';
|
||||||
import { BackendCard } from '../ui/BackendCard';
|
import { BackendCard } from '../ui/BackendCard';
|
||||||
import { MD3_BTN_FILLED } from '../../styles/md3-buttons';
|
import { MD3_BTN_FILLED } from '../../styles/md3-buttons';
|
||||||
import { TextFieldMD3 } from '../ui/TextFieldMD3';
|
import { TextFieldMD3 } from '../ui/TextFieldMD3';
|
||||||
import { RemoteNamePreview } from '../ui/RemoteNamePreview';
|
import { RemoteNamePreview } from '../ui/RemoteNamePreview';
|
||||||
|
import { BACKEND_ICONS } from '../icons/BackendIcons';
|
||||||
|
|
||||||
const remoteNameSchema = z.object({
|
const remoteNameSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -22,10 +24,30 @@ const remoteNameSchema = z.object({
|
|||||||
|
|
||||||
type RemoteNameFormValues = z.infer<typeof remoteNameSchema>;
|
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() {
|
export function BackendSelectionStep() {
|
||||||
const { state, dispatch } = useWizard();
|
const { state, dispatch } = useWizard();
|
||||||
const pendingBackend = useRef<BackendType | null>(null);
|
const pendingBackend = useRef<BackendType | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
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."
|
helpText="This becomes the section header [name] in your rclone.conf. Example: azure-prod, backup-s3. Letters, numbers, dashes, underscores only."
|
||||||
/>
|
/>
|
||||||
<RemoteNamePreview value={remoteName} />
|
<RemoteNamePreview value={remoteName} />
|
||||||
<div data-testid="backend-cards" className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
|
<input
|
||||||
{Object.entries(BACKEND_REGISTRY).map(([type, entry]) => (
|
type="search"
|
||||||
<BackendCard
|
value={searchQuery}
|
||||||
key={type}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
name={entry.displayName}
|
placeholder="Search backends..."
|
||||||
description={entry.description}
|
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"
|
||||||
selected={state.remote.backendType === type}
|
/>
|
||||||
onClick={() => handleCardClick(type as BackendType)}
|
<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>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mt-6">
|
<div className="flex flex-col sm:flex-row gap-3 mt-6">
|
||||||
<button type="submit" className={`${MD3_BTN_FILLED} w-full sm:w-auto`}>
|
<button type="submit" className={`${MD3_BTN_FILLED} w-full sm:w-auto`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user