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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user