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