Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/frontend-wizard-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"frontend": patch
---

Fix wizard state-loss bugs (existing metadata no longer reloads on revisit, additive vs replace upload flows keep metadata and staged data consistent, navigation locked during processing, picked files survive navigation); bound zip download/upload memory (sequential compression with backpressure, blob-based zip extraction); accessibility pass (aria-live regions, native dialog for JSON preview, label associations); beforeunload guard for unsaved work.
14 changes: 0 additions & 14 deletions packages/frontend/src/assets/downarrow.svg

This file was deleted.

14 changes: 0 additions & 14 deletions packages/frontend/src/assets/plus.svg

This file was deleted.

1 change: 0 additions & 1 deletion packages/frontend/src/assets/react.svg

This file was deleted.

12 changes: 0 additions & 12 deletions packages/frontend/src/assets/trash.svg

This file was deleted.

14 changes: 0 additions & 14 deletions packages/frontend/src/assets/uparrow.svg

This file was deleted.

75 changes: 64 additions & 11 deletions packages/frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import JsPsychMetadata from '@jspsych/metadata';
import Sidebar from './Sidebar';
import PreviewDrawer from './PreviewDrawer';
import ProjectInfo, { ProjectInfoSession, emptyProjectInfoSession } from '../pages/ProjectInfo';
import ProjectInfo, { ProjectInfoSession, emptyProjectInfoSession, applyProjectInfoFields } from '../pages/ProjectInfo';
import DataUpload, { DataSession, emptyDataSession } from '../pages/DataUpload';
import Variables from '../pages/Variables';
import Authors from '../pages/Authors';
Expand All @@ -26,20 +26,64 @@ interface AppShellProps {
}

const AppShell: React.FC<AppShellProps> = ({ jsPsychMetadata, existingMetadataFile, onStartOver }) => {
const isExistingProject = !!existingMetadataFile;

const [currentStep, setCurrentStep] = useState<StepId>('projectInfo');
// Pre-complete the Data step for existing projects — variables are already loaded from the JSON
const [completedSteps, setCompletedSteps] = useState<Set<StepId>>(
() => isExistingProject ? new Set<StepId>(['data']) : new Set<StepId>()
);
const [completedSteps, setCompletedSteps] = useState<Set<StepId>>(() => new Set<StepId>());
const [dataProcessed, setDataProcessed] = useState(false);
const [dataBusy, setDataBusy] = useState(false);
const [dataSession, setDataSession] = useState<DataSession>(emptyDataSession);
const [projectInfoSession, setProjectInfoSession] = useState<ProjectInfoSession>(
() => emptyProjectInfoSession()
);
const [previewOpen, setPreviewOpen] = useState(false);

// An existing project's Data step is only "done for free" once its metadata actually loaded —
// a failed parse must not pre-complete Data or claim variables were loaded from it.
const existingLoaded = projectInfoSession.loadStatus === 'loaded';

// Pre-complete the Data step when an existing project's metadata loads successfully — its
// variables come from the JSON, so no data upload is required to advance.
useEffect(() => {
if (existingLoaded) {
setCompletedSteps(prev => (prev.has('data') ? prev : new Set([...prev, 'data'])));
}
}, [existingLoaded]);

// Latest project-info fields, read when rebuilding metadata after a data replace.
const projectInfoSessionRef = useRef(projectInfoSession);
projectInfoSessionRef.current = projectInfoSession;

// Full data reset for the "replace all data" flow: drop every generated variable so the
// metadata no longer describes the discarded dataset, then (for an existing project) restore
// the uploaded metadata file's variables and re-apply the user's edited project-info fields.
const resetMetadata = useCallback(async () => {
for (const name of jsPsychMetadata.getVariableNames()) jsPsychMetadata.deleteVariable(name);
if (existingMetadataFile) {
try {
jsPsychMetadata.loadMetadata(await existingMetadataFile.text());
} catch {
/* leave the cleared state if the file no longer parses */
}
applyProjectInfoFields(jsPsychMetadata, projectInfoSessionRef.current);
}
}, [jsPsychMetadata, existingMetadataFile]);

// Warn before an accidental tab close/reload while there's unsaved work (files staged or metadata
// edited) that hasn't been downloaded yet — nothing is persisted server-side. Lifted once the
// user downloads their dataset.
const [downloaded, setDownloaded] = useState(false);
const hasUnsavedWork =
!downloaded &&
(dataSession.files.length > 0 ||
(dataSession.convertedStore?.paths().length ?? 0) > 0 ||
projectInfoSession.name.trim() !== '' ||
projectInfoSession.description.trim() !== '');
useEffect(() => {
if (!hasUnsavedWork) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [hasUnsavedWork]);

// Discard this session's on-disk staging before tearing the shell down — Start Over throws the
// whole project away, so the converted CSVs/raw originals shouldn't linger in OPFS (otherwise
// they sit there until the next startup sweep). Fire-and-forget: clear() swallows its own errors
Expand Down Expand Up @@ -90,8 +134,10 @@ const AppShell: React.FC<AppShellProps> = ({ jsPsychMetadata, existingMetadataFi
<DataUpload
jsPsychMetadata={jsPsychMetadata}
dataProcessed={dataProcessed}
existingMetadataLoaded={isExistingProject}
existingMetadataLoaded={existingLoaded}
onComplete={() => { setDataProcessed(true); completeStep('data'); }}
onResetMetadata={resetMetadata}
onBusyChange={setDataBusy}
session={dataSession}
onSessionChange={setDataSession}
/>
Expand All @@ -101,7 +147,13 @@ const AppShell: React.FC<AppShellProps> = ({ jsPsychMetadata, existingMetadataFi
case 'authors':
return <Authors jsPsychMetadata={jsPsychMetadata} onComplete={() => completeStep('authors')} />;
case 'review':
return <Review jsPsychMetadata={jsPsychMetadata} dataFiles={dataSession.convertedStore} />;
return (
<Review
jsPsychMetadata={jsPsychMetadata}
dataFiles={dataSession.convertedStore}
onDownloaded={() => setDownloaded(true)}
/>
);
}
};

Expand All @@ -112,8 +164,9 @@ const AppShell: React.FC<AppShellProps> = ({ jsPsychMetadata, existingMetadataFi
currentStep={currentStep}
completedSteps={completedSteps}
canNavigateTo={canNavigateTo}
onNavigate={(stepId) => { if (canNavigateTo(stepId)) setCurrentStep(stepId); }}
onNavigate={(stepId) => { if (!dataBusy && canNavigateTo(stepId)) setCurrentStep(stepId); }}
onStartOver={handleStartOver}
locked={dataBusy}
/>
<main className={styles.content}>
{renderStep()}
Expand Down
17 changes: 10 additions & 7 deletions packages/frontend/src/components/PreviewDrawer.module.css
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 30;
}

.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: auto;
width: 420px;
max-width: calc(100vw - var(--sidebar-w)); /* never wider than the content area */
max-height: 100vh;
margin: 0; /* override the UA-centred dialog placement */
padding: 0;
background: var(--c-bg-raised);
color: inherit;
border: none;
border-left: 1px solid var(--c-border);
z-index: 31;
display: flex;
flex-direction: column;
animation: slideIn 0.2s cubic-bezier(0, 0, 0.2, 1);
}

.drawer::backdrop {
background: rgba(0, 0, 0, 0.35);
}

@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
Expand Down
40 changes: 27 additions & 13 deletions packages/frontend/src/components/PreviewDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useEffect } from 'react';
import { useMemo, useLayoutEffect, useRef } from 'react';
import JsPsychMetadata from '@jspsych/metadata';
import JsonViewer from './JsonViewer';
import styles from './PreviewDrawer.module.css';
Expand All @@ -11,25 +11,39 @@ interface PreviewDrawerProps {
const PreviewDrawer: React.FC<PreviewDrawerProps> = ({ jsPsychMetadata, onClose }) => {
// Fresh snapshot on each open (component mounts when drawer opens)
const data = useMemo(() => jsPsychMetadata.getMetadata(), []);
const dialogRef = useRef<HTMLDialogElement>(null);

useEffect(() => {
// A native <dialog> opened with showModal() gives a real focus trap, Escape-to-close, and an
// inert backdrop for free (mirroring Sidebar's confirm dialog). Escape fires 'cancel' → onClose.
useLayoutEffect(() => {
const dialog = dialogRef.current;
if (dialog && !dialog.open) dialog.showModal();
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);

// A click landing on the dialog element itself (not its content) is a backdrop click → close.
const handleClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === dialogRef.current) onClose();
};

return (
<>
<div className={styles.backdrop} onClick={onClose} aria-hidden="true" />
<div className={styles.drawer} role="dialog" aria-label="JSON preview">
<div className={styles.drawerHeader}>
<span className={styles.drawerTitle}>JSON Preview</span>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close preview">×</button>
</div>
<div className={styles.drawerBody}>
<JsonViewer data={data} />
</div>
<dialog
ref={dialogRef}
className={styles.drawer}
aria-label="JSON preview"
onClose={onClose}
onCancel={onClose}
onClick={handleClick}
>
<div className={styles.drawerHeader}>
<span className={styles.drawerTitle}>JSON Preview</span>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close preview">×</button>
</div>
<div className={styles.drawerBody}>
<JsonViewer data={data} />
</div>
</>
</dialog>
);
};

Expand Down
7 changes: 5 additions & 2 deletions packages/frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SidebarProps {
canNavigateTo: (stepId: StepId) => boolean;
onNavigate: (stepId: StepId) => void;
onStartOver: () => void;
/** When true (e.g. data is processing) all navigation is disabled so a run can't be orphaned. */
locked?: boolean;
}

const Sidebar: React.FC<SidebarProps> = ({
Expand All @@ -24,6 +26,7 @@ const Sidebar: React.FC<SidebarProps> = ({
canNavigateTo,
onNavigate,
onStartOver,
locked = false,
}) => {
const [confirming, setConfirming] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
Expand All @@ -42,7 +45,7 @@ const Sidebar: React.FC<SidebarProps> = ({
{steps.map((step) => {
const isActive = step.id === currentStep;
const isCompleted = completedSteps.has(step.id);
const isLocked = !canNavigateTo(step.id) && !isActive;
const isLocked = locked || (!canNavigateTo(step.id) && !isActive);

const cls = [
styles.step,
Expand Down Expand Up @@ -79,7 +82,7 @@ const Sidebar: React.FC<SidebarProps> = ({
>
What is Psych-DS? ↗
</a>
<button className={styles.startOver} onClick={() => setConfirming(true)}>
<button className={styles.startOver} onClick={() => setConfirming(true)} disabled={locked}>
← Start over
</button>
</div>
Expand Down
12 changes: 0 additions & 12 deletions packages/frontend/src/global.d.ts

This file was deleted.

Loading
Loading