mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(onboarding): gate Next on backup test with select-button dropzone
- BackupTestFlow.tsx: replace the always-on dashed dropzone with an
oversized onboarding-style primary pill ("Select your backup file",
h-14, rounded-full, bg-primary with the onboarding CTA label color)
that opens the file browser; window-level dragenter/dragleave
listeners (depth-counted, filtered to file drags) morph it back into
the dashed drop target only while a file is dragged over the window
- BackupTestFlow.tsx: restyle "Re-download backup" as the flow's ghost
pill secondary (h-12, rounded-full, bg-foreground/10) and move the
"Saved to <path>" label below the button in a centered column
- EncryptedBackupCreator.tsx: add onVerified prop and forward it to
BackupTestFlow so parents can observe a passed backup test
- DownloadKeyStep.tsx: track hasVerified; after the backup is created,
Next renders disabled until the test passes while a ghost "Skip for
now" button hangs off its right edge as the escape hatch; retitle the
post-download page to "Now, test your backup"
- onboarding-backup.spec.ts: assert the new heading, the disabled
Next + visible Skip state after download, and that a passed test
enables Next and removes Skip (all 8 tests passing)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -113,6 +113,38 @@ export function BackupTestFlow({
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
const [stage, setStage] = React.useState<BackupTestStage>("drop");
|
||||
const [isDragActive, setIsDragActive] = React.useState(false);
|
||||
// True while a file drag is anywhere over the window — the select button
|
||||
// renders as a dropzone only for the duration of the drag.
|
||||
const [isWindowDragging, setIsWindowDragging] = React.useState(false);
|
||||
const dragDepthRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
// dragenter/dragleave fire per nested element, so track depth to know
|
||||
// when the drag has actually left the window.
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsWindowDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => {
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsWindowDragging(false);
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
dragDepthRef.current = 0;
|
||||
setIsWindowDragging(false);
|
||||
};
|
||||
window.addEventListener("dragenter", handleDragEnter);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
window.addEventListener("drop", handleDragEnd);
|
||||
window.addEventListener("dragend", handleDragEnd);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleDragEnter);
|
||||
window.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("drop", handleDragEnd);
|
||||
window.removeEventListener("dragend", handleDragEnd);
|
||||
};
|
||||
}, []);
|
||||
const [fileName, setFileName] = React.useState<string | null>(null);
|
||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||
const [attempt, setAttempt] = React.useState("");
|
||||
@@ -249,11 +281,16 @@ export function BackupTestFlow({
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 text-center transition-colors",
|
||||
isSpotlight ? "h-44" : "h-32",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-foreground/25 bg-background/40 hover:border-foreground/40 hover:bg-background/70",
|
||||
"flex flex-col items-center justify-center gap-2 text-center transition-all",
|
||||
isWindowDragging
|
||||
? cn(
|
||||
"w-full rounded-2xl border-2 border-dashed px-6",
|
||||
isSpotlight ? "h-44" : "h-32",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-foreground/25 bg-background/40",
|
||||
)
|
||||
: "mx-auto h-14 rounded-full bg-primary px-12 text-(--buzz-onboarding-cta-label) shadow hover:bg-primary/90",
|
||||
)}
|
||||
data-testid="backup-test-dropzone"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -270,19 +307,24 @@ export function BackupTestFlow({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<FileUp
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-colors",
|
||||
isDragActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Drop your backup file here
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
or click to browse for it
|
||||
</span>
|
||||
{isWindowDragging ? (
|
||||
<>
|
||||
<FileUp
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-8 w-8 transition-colors",
|
||||
isDragActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Drop your backup file here
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-base font-medium">
|
||||
Select your backup file
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{fileError ? (
|
||||
<p
|
||||
@@ -294,15 +336,14 @@ export function BackupTestFlow({
|
||||
) : null}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-sm"
|
||||
className="h-12 gap-1.5 rounded-full bg-foreground/10 px-10 text-base hover:bg-foreground/15"
|
||||
data-testid="encrypted-backup-save-copy"
|
||||
disabled={isSaving}
|
||||
onClick={onSaveCopy}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
|
||||
{isSaving ? <Spinner className="h-4 w-4 border-2" /> : null}
|
||||
Re-download backup
|
||||
</Button>
|
||||
{savedPath ? (
|
||||
|
||||
@@ -32,6 +32,9 @@ export function DownloadKeyStep({
|
||||
// True once the encrypted payload exists — the create button (living in the
|
||||
// footer's primary slot) disappears with the form, so Next takes its place.
|
||||
const [hasCreated, setHasCreated] = React.useState(false);
|
||||
// True once the user has passed the backup test — until then Next stays
|
||||
// disabled and "Skip for now" remains the escape hatch.
|
||||
const [hasVerified, setHasVerified] = React.useState(false);
|
||||
// Footer slot the creator portals its "Download" button into.
|
||||
const [createButtonSlot, setCreateButtonSlot] =
|
||||
React.useState<HTMLElement | null>(null);
|
||||
@@ -47,7 +50,9 @@ export function DownloadKeyStep({
|
||||
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
|
||||
text-title size token as conflicting with text-foreground. */}
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
{hasCreated ? "Test your backup" : "Backup your key with a password"}
|
||||
{hasCreated
|
||||
? "Now, test your backup"
|
||||
: "Backup your key with a password"}
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
{hasCreated
|
||||
@@ -69,6 +74,7 @@ export function DownloadKeyStep({
|
||||
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
createButtonPortal={createButtonSlot}
|
||||
onCreated={() => setHasCreated(true)}
|
||||
onVerified={() => setHasVerified(true)}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</div>
|
||||
@@ -79,14 +85,30 @@ export function DownloadKeyStep({
|
||||
|
||||
<OnboardingFooter>
|
||||
{hasCreated ? (
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
/* Relative row keeps Next truly centered while Skip hangs off its
|
||||
right edge without shifting the center. */
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={!hasVerified}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
{hasVerified ? null : (
|
||||
<Button
|
||||
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
|
||||
data-testid="onboarding-skip"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Relative row keeps the Download CTA truly centered while Skip
|
||||
hangs off its right edge without shifting the center. */
|
||||
|
||||
@@ -144,6 +144,8 @@ type EncryptedBackupCreatorProps = {
|
||||
onCreated?: () => void;
|
||||
/** Fired only after the encrypted key file has been saved successfully. */
|
||||
onSaved?: (path: string) => void;
|
||||
/** Fired once when the user completes the backup test successfully. */
|
||||
onVerified?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -324,6 +326,7 @@ export function EncryptedBackupCreator({
|
||||
createButtonClassName,
|
||||
onCreated,
|
||||
onSaved,
|
||||
onVerified,
|
||||
}: EncryptedBackupCreatorProps) {
|
||||
const [state, dispatch] = React.useReducer(
|
||||
encryptedBackupReducer,
|
||||
@@ -449,6 +452,7 @@ export function EncryptedBackupCreator({
|
||||
isSaving={isSaving}
|
||||
ncryptsec={state.ncryptsec}
|
||||
onSaveCopy={() => void handleSaveCopy()}
|
||||
onVerified={onVerified}
|
||||
passphrase={state.passphrase}
|
||||
saveError={saveError}
|
||||
savedPath={savedPath}
|
||||
|
||||
@@ -148,10 +148,11 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
|
||||
// Download commits the blob and hands over to the "Test your backup"
|
||||
// flow: a dropzone for the saved file, then the password to unlock it.
|
||||
// Download commits the blob and hands over to the "Now, test your backup"
|
||||
// flow: a select-file button (dropzone while dragging) for the saved file,
|
||||
// then the password to unlock it.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Test your backup" }),
|
||||
page.getByRole("heading", { name: "Now, test your backup" }),
|
||||
).toBeVisible();
|
||||
const dropzone = page.getByTestId("backup-test-dropzone");
|
||||
await expect(dropzone).toBeVisible();
|
||||
@@ -159,6 +160,11 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
"identity.ncryptsec",
|
||||
);
|
||||
|
||||
// Until the test passes, Next stays disabled and Skip remains the escape
|
||||
// hatch.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
await expect(page.getByTestId("onboarding-skip")).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
|
||||
|
||||
@@ -203,7 +209,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
|
||||
expect(commands).not.toContain("get_nsec");
|
||||
expect(commands).toContain("create_ncryptsec_backup");
|
||||
|
||||
// A passed test unlocks Next and retires the Skip escape hatch.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await expect(page.getByTestId("onboarding-skip")).toHaveCount(0);
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user