diff --git a/.changeset/frontend-wizard-fixes.md b/.changeset/frontend-wizard-fixes.md new file mode 100644 index 0000000..c81f517 --- /dev/null +++ b/.changeset/frontend-wizard-fixes.md @@ -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. diff --git a/packages/frontend/src/assets/downarrow.svg b/packages/frontend/src/assets/downarrow.svg deleted file mode 100644 index b2e3bb2..0000000 --- a/packages/frontend/src/assets/downarrow.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/frontend/src/assets/plus.svg b/packages/frontend/src/assets/plus.svg deleted file mode 100644 index d594686..0000000 --- a/packages/frontend/src/assets/plus.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - plus-circle - Created with Sketch Beta. - - - - - - - - - \ No newline at end of file diff --git a/packages/frontend/src/assets/react.svg b/packages/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/packages/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/frontend/src/assets/trash.svg b/packages/frontend/src/assets/trash.svg deleted file mode 100644 index ef89567..0000000 --- a/packages/frontend/src/assets/trash.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/packages/frontend/src/assets/uparrow.svg b/packages/frontend/src/assets/uparrow.svg deleted file mode 100644 index 7f7517c..0000000 --- a/packages/frontend/src/assets/uparrow.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - \ No newline at end of file diff --git a/packages/frontend/src/components/AppShell.tsx b/packages/frontend/src/components/AppShell.tsx index 53859ee..60664f6 100644 --- a/packages/frontend/src/components/AppShell.tsx +++ b/packages/frontend/src/components/AppShell.tsx @@ -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'; @@ -26,20 +26,64 @@ interface AppShellProps { } const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFile, onStartOver }) => { - const isExistingProject = !!existingMetadataFile; - const [currentStep, setCurrentStep] = useState('projectInfo'); - // Pre-complete the Data step for existing projects — variables are already loaded from the JSON - const [completedSteps, setCompletedSteps] = useState>( - () => isExistingProject ? new Set(['data']) : new Set() - ); + const [completedSteps, setCompletedSteps] = useState>(() => new Set()); const [dataProcessed, setDataProcessed] = useState(false); + const [dataBusy, setDataBusy] = useState(false); const [dataSession, setDataSession] = useState(emptyDataSession); const [projectInfoSession, setProjectInfoSession] = useState( () => 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 @@ -90,8 +134,10 @@ const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFi { setDataProcessed(true); completeStep('data'); }} + onResetMetadata={resetMetadata} + onBusyChange={setDataBusy} session={dataSession} onSessionChange={setDataSession} /> @@ -101,7 +147,13 @@ const AppShell: React.FC = ({ jsPsychMetadata, existingMetadataFi case 'authors': return completeStep('authors')} />; case 'review': - return ; + return ( + setDownloaded(true)} + /> + ); } }; @@ -112,8 +164,9 @@ const AppShell: React.FC = ({ 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} />
{renderStep()} diff --git a/packages/frontend/src/components/PreviewDrawer.module.css b/packages/frontend/src/components/PreviewDrawer.module.css index 4e97c92..980638e 100644 --- a/packages/frontend/src/components/PreviewDrawer.module.css +++ b/packages/frontend/src/components/PreviewDrawer.module.css @@ -1,18 +1,17 @@ -.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; @@ -20,6 +19,10 @@ 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); } diff --git a/packages/frontend/src/components/PreviewDrawer.tsx b/packages/frontend/src/components/PreviewDrawer.tsx index 5db8813..ebeba00 100644 --- a/packages/frontend/src/components/PreviewDrawer.tsx +++ b/packages/frontend/src/components/PreviewDrawer.tsx @@ -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'; @@ -11,25 +11,39 @@ interface PreviewDrawerProps { const PreviewDrawer: React.FC = ({ jsPsychMetadata, onClose }) => { // Fresh snapshot on each open (component mounts when drawer opens) const data = useMemo(() => jsPsychMetadata.getMetadata(), []); + const dialogRef = useRef(null); - useEffect(() => { + // A native 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) => { + if (e.target === dialogRef.current) onClose(); + }; + return ( - <> - ); @@ -481,16 +612,16 @@ const DataUpload: React.FC = ({ {/* Pickers */}
- or - - {files.length > 0 && sourceName && ( + {batch.length > 0 && sourceName && ( - {sourceName} ({files.length} file{files.length !== 1 ? 's' : ''}) + {sourceName} ({batch.length} file{batch.length !== 1 ? 's' : ''}) )} = ({ multiple style={{ display: 'none' }} onChange={handleFolderChange} + {...{ webkitdirectory: '' }} /> = ({ onChange={handleZipChange} />
- {pickError &&

{pickError}

} + {pickError &&

{pickError}

} + + {replaceConfirm && ( + setReplaceConfirm(null)} /> + )} {/* File list (before processing) */} {phase === 'ready' && ( <>
    - {files.map(f => ( + {batch.map(f => (
  • · {f.webkitRelativePath || f.name} @@ -529,7 +665,7 @@ const DataUpload: React.FC = ({ {/* Pre-flight spinner */} {phase === 'preflight' && ( -

    Reading files…

    +

    Reading files…

    )} {/* Join key chooser */} @@ -617,15 +753,26 @@ const DataUpload: React.FC = ({ {/* Per-file status (processing + done) */} {(phase === 'processing' || phase === 'done') && ( -
      - {fileStatuses.map((s, i) => ( -
    • - {statusIcon(s.status)} - {s.name} - {s.detail && {s.detail}} -
    • - ))} -
    + <> +

    + {(() => { + const total = fileStatuses.length; + const done = fileStatuses.filter(s => s.status !== 'pending' && s.status !== 'loading').length; + return phase === 'processing' + ? `Processing… ${done} of ${total} file${total !== 1 ? 's' : ''} processed.` + : `Done. ${done} of ${total} file${total !== 1 ? 's' : ''} processed.`; + })()} +

    +
      + {fileStatuses.map((s, i) => ( +
    • + {statusIcon(s.status)} + {s.name} + {s.detail && {s.detail}} +
    • + ))} +
    + )} {phase === 'done' && ( diff --git a/packages/frontend/src/pages/ProjectInfo.tsx b/packages/frontend/src/pages/ProjectInfo.tsx index c378861..01a5378 100644 --- a/packages/frontend/src/pages/ProjectInfo.tsx +++ b/packages/frontend/src/pages/ProjectInfo.tsx @@ -14,11 +14,26 @@ export const OPTIONAL_FIELDS: { key: string; label: string; hint: string; help?: help: 'Choose the option that matches your IRB approval or data-sharing agreement:\n• open — data can be shared publicly without restriction\n• open_deidentified — data can be shared after removing directly identifying information (names, dates of birth, etc.)\n• open_redacted — data can be shared after removing specific sensitive fields\n• private — data is not to be shared outside your team' }, ]; +export type MetadataLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'; + export type ProjectInfoSession = { name: string; description: string; optional: Record; optionalOpen: boolean; + /** + * Outcome of loading an existing `dataset_description.json` into this session. Lives at the + * AppShell level (via the session) so it survives page remounts and gates whether the Data + * step is pre-completed and shown as "variables loaded from existing metadata" — a failed + * parse must not look like a successful load. + */ + loadStatus: MetadataLoadStatus; + /** + * Identity of the existing-metadata file this session was loaded from (name:size:lastModified), + * or null if none. The load effect runs exactly once per file identity: on remount, a matching + * token means the load already happened, so it is not re-run (which would clobber session edits). + */ + loadToken: string | null; }; export const emptyProjectInfoSession = (): ProjectInfoSession => ({ @@ -26,8 +41,31 @@ export const emptyProjectInfoSession = (): ProjectInfoSession => ({ description: '', optional: Object.fromEntries(OPTIONAL_FIELDS.map(f => [f.key, ''])), optionalOpen: false, + loadStatus: 'idle', + loadToken: null, }); +/** Stable identity for an uploaded file, used to load its metadata exactly once. */ +const fileIdentity = (file: File): string => `${file.name}:${file.size}:${file.lastModified}`; + +/** + * Writes the project-info fields (name, description, optional) into the metadata instance — + * shared by Continue and by the data-replace reset, which reloads existing metadata and then + * re-applies the user's edited fields on top. + */ +export function applyProjectInfoFields(meta: JsPsychMetadata, session: ProjectInfoSession): void { + meta.setMetadataField('name', session.name.trim()); + meta.setMetadataField('description', session.description.trim() || 'No description provided.'); + for (const { key } of OPTIONAL_FIELDS) { + const val = (session.optional[key] ?? '').trim(); + if (val) { + meta.setMetadataField(key, val); + } else { + meta.deleteMetadataField(key); + } + } +} + interface ProjectInfoProps { jsPsychMetadata: JsPsychMetadata; existingMetadataFile?: File; @@ -43,7 +81,14 @@ const ProjectInfo: React.FC = ({ onSessionChange, onComplete, }) => { - const [loadStatus, setLoadStatus] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const fileToken = existingMetadataFile ? fileIdentity(existingMetadataFile) : null; + // Has this exact file already been loaded (or attempted) into the session? If so, don't reload + // on remount — that would clobber edits the user made on other steps. + const alreadyAttempted = fileToken !== null && session.loadToken === fileToken; + + const [loadStatus, setLoadStatus] = useState( + alreadyAttempted ? session.loadStatus : existingMetadataFile ? 'loading' : 'idle', + ); const [error, setError] = useState(''); const [helpOpen, setHelpOpen] = useState(null); const [pendingUpload, setPendingUpload] = useState | null>(null); @@ -55,7 +100,11 @@ const ProjectInfo: React.FC = ({ const toggleHelp = (key: string) => setHelpOpen(prev => prev === key ? null : key); useEffect(() => { - if (!existingMetadataFile) return; + if (!existingMetadataFile || fileToken === null) return; + // Load the existing metadata exactly once per uploaded file. A matching token means this file + // was already loaded into the session on an earlier mount; re-running loadMetadata would + // resurrect deleted authors / revert edited variables and clobber the form session. + if (session.loadToken === fileToken) return; setLoadStatus('loading'); const reader = new FileReader(); reader.onload = () => { @@ -69,16 +118,22 @@ const ProjectInfo: React.FC = ({ description: jsPsychMetadata.getMetadataField('description') as string || '', optional: optionalVals, optionalOpen: OPTIONAL_FIELDS.some(f => !!jsPsychMetadata.getMetadataField(f.key)), + loadStatus: 'loaded', + loadToken: fileToken, }); setLoadStatus('loaded'); } catch { setLoadStatus('error'); setError('Failed to parse the metadata file — check that it is valid JSON.'); + // Record the attempt (so it isn't retried) and propagate the failure so the Data step + // isn't pre-completed or shown as "variables loaded from existing metadata". + onSessionChange({ ...session, loadStatus: 'error', loadToken: fileToken }); } }; reader.onerror = () => { setLoadStatus('error'); setError('Failed to read the file.'); + onSessionChange({ ...session, loadStatus: 'error', loadToken: fileToken }); }; reader.readAsText(existingMetadataFile); }, [existingMetadataFile]); @@ -144,19 +199,7 @@ const ProjectInfo: React.FC = ({ const handleContinue = () => { if (!session.name.trim()) { setError('Project name is required.'); return; } setError(''); - - jsPsychMetadata.setMetadataField('name', session.name.trim()); - jsPsychMetadata.setMetadataField('description', session.description.trim() || 'No description provided.'); - - for (const { key } of OPTIONAL_FIELDS) { - const val = (session.optional[key] ?? '').trim(); - if (val) { - jsPsychMetadata.setMetadataField(key, val); - } else { - jsPsychMetadata.deleteMetadataField(key); - } - } - + applyProjectInfoFields(jsPsychMetadata, session); onComplete(); }; @@ -382,7 +425,7 @@ const ProjectInfo: React.FC = ({ )} - {error &&

    {error}

    } + {error &&

    {error}

    } {zipError && ( -
    +
    {zipError}
    )} @@ -178,6 +196,7 @@ const Review: React.FC = ({ jsPsychMetadata, dataFiles }) => { : 'Re-validate'} +
    {valStatus === 'unavailable' && valError && (
    {valError} @@ -244,6 +263,7 @@ const Review: React.FC = ({ jsPsychMetadata, dataFiles }) => { )} )} +
    Prefer the command line? diff --git a/packages/frontend/src/pages/Variables.tsx b/packages/frontend/src/pages/Variables.tsx index 5afd3bd..953c5d5 100644 --- a/packages/frontend/src/pages/Variables.tsx +++ b/packages/frontend/src/pages/Variables.tsx @@ -123,6 +123,13 @@ const Variables: React.FC = ({ jsPsychMetadata, onComplete }) => ? (showAllLevels ? levels : levels.slice(0, LEVELS_PREVIEW)) : []; const hasMoreLevels = levels && levels.length > LEVELS_PREVIEW; + // Per-variable ids tying each control to its label. Encode the name so unusual column names + // (spaces, punctuation) can't break the id/htmlFor pairing. + const uid = encodeURIComponent(v.name).replace(/%/g, '_'); + const descId = `var-desc-${uid}`; + const typeId = `var-type-${uid}`; + const levelsLabelId = `var-levels-${uid}`; + const rangeLabelId = `var-range-${uid}`; return (
  • = ({ jsPsychMetadata, onComplete }) =>
    +