feat(13-02): implement OAuthInstructions component with tests

- Collapsible disclosure with expanded=false by default
- Shows backend-specific rclone authorize command in code block
- Accepts custom steps array or defaults to 5-step generic OAuth flow
- Styled with MD3 tokens (bg-surface-container, border-outline, rounded-xl)
- All 4 behavior tests pass (TDD)
This commit is contained in:
2026-04-01 16:25:18 +02:00
parent f7a79647f0
commit e00fc2b03b
2 changed files with 127 additions and 0 deletions
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
// Tests for OAuthInstructions — collapsible OAuth step-by-step guide
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OAuthInstructions } from './OAuthInstructions';
describe('OAuthInstructions', () => {
it('renders collapsed by default (instructions not visible)', () => {
render(
<OAuthInstructions
backendName="Google Drive"
authorizeCommand='rclone authorize "drive"'
/>
);
// The numbered steps should not be visible in collapsed state
expect(screen.queryByText(/Install rclone/i)).toBeNull();
});
it('expands when user clicks the toggle button, showing numbered steps', async () => {
const user = userEvent.setup();
render(
<OAuthInstructions
backendName="Google Drive"
authorizeCommand='rclone authorize "drive"'
/>
);
const toggleBtn = screen.getByRole('button');
await user.click(toggleBtn);
// After expanding, steps should be visible
expect(screen.getByText(/Install rclone/i)).toBeDefined();
});
it('shows the backend-specific authorizeCommand in the expanded content', async () => {
const user = userEvent.setup();
const cmd = 'rclone authorize "drive"';
render(
<OAuthInstructions
backendName="Google Drive"
authorizeCommand={cmd}
/>
);
const toggleBtn = screen.getByRole('button');
await user.click(toggleBtn);
expect(screen.getByText(cmd)).toBeDefined();
});
it('shows backend name in the toggle button text', () => {
render(
<OAuthInstructions
backendName="Dropbox"
authorizeCommand='rclone authorize "dropbox"'
/>
);
const toggleBtn = screen.getByRole('button');
expect(toggleBtn.textContent).toContain('Dropbox');
});
});
@@ -0,0 +1,69 @@
import { useState } from 'react';
interface OAuthInstructionsProps {
backendName: string; // e.g. "Google Drive"
authorizeCommand: string; // e.g. 'rclone authorize "drive"'
steps?: string[]; // optional custom steps (defaults to generic OAuth flow)
}
const DEFAULT_STEPS = (authorizeCommand: string): string[] => [
'Install rclone on a machine that has a web browser.',
`Run the authorize command: ${authorizeCommand}`,
'A browser window will open — authenticate with your account.',
'Copy the JSON token printed in the terminal.',
'Paste the token into the field above.',
];
export function OAuthInstructions({ backendName, authorizeCommand, steps }: OAuthInstructionsProps) {
const [expanded, setExpanded] = useState(false);
const resolvedSteps = steps ?? DEFAULT_STEPS(authorizeCommand);
return (
<div className="rounded-xl border border-outline bg-surface-container text-on-surface">
<button
type="button"
onClick={() => setExpanded(v => !v)}
className="flex w-full items-center justify-between px-4 py-2.5 text-sm font-medium"
aria-expanded={expanded}
>
<span>How to get your {backendName} OAuth token</span>
{/* Inline chevron SVG */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={['w-4 h-4 transition-transform', expanded ? 'rotate-180' : ''].join(' ')}
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.53 16.28a.75.75 0 01-1.06 0l-7.5-7.5a.75.75 0 011.06-1.06L12 14.69l6.97-6.97a.75.75 0 111.06 1.06l-7.5 7.5z"
clipRule="evenodd"
/>
</svg>
</button>
{expanded && (
<div className="border-t border-outline px-4 py-3">
<ol className="flex flex-col gap-2 list-decimal list-inside text-sm text-on-surface-variant">
{resolvedSteps.map((step, index) => {
// The second step contains the command — render it with a code block
if (step.startsWith('Run the authorize command:')) {
return (
<li key={index}>
Run the authorize command:{' '}
<code className="font-mono bg-surface px-1.5 py-0.5 rounded text-on-surface text-xs">
{authorizeCommand}
</code>
</li>
);
}
return <li key={index}>{step}</li>;
})}
</ol>
</div>
)}
</div>
);
}