From a5c91b5f24b17c752acd90f9a9f75a30261714b4 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 16:54:07 +0000 Subject: [PATCH] hoist the SongBook import Save action into PageHeader so it stays above the fold (#6001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a ~375px viewport the form's Save sits below the tab bar, a 12-row textarea and a 50vh live preview — roughly 1000px down, so a user who pastes a tab has to scroll three viewports to submit and back again to edit. SongBookViewer already keeps its Save in PageHeader; mirror that. The form's submit button stays put so Enter-to-submit and the bottom-of-form path keep working, and the back link's label collapses on mobile to make room for the new button. --- client/src/pages/SongBookImport.jsx | 26 +++++++++++++++++++----- client/src/pages/SongBookImport.test.jsx | 18 ++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/client/src/pages/SongBookImport.jsx b/client/src/pages/SongBookImport.jsx index 5ccdb568cb..c66f988d44 100644 --- a/client/src/pages/SongBookImport.jsx +++ b/client/src/pages/SongBookImport.jsx @@ -11,7 +11,8 @@ * stored until the user reviews and Saves. * * Below either tab: a draft form (title/artist/instrument/stage/tags) → Save → - * createSong → navigate to the new song's viewer. + * createSong → navigate to the new song's viewer. PageHeader carries a second + * Save so the primary action stays above the fold on a phone. */ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; @@ -230,10 +231,25 @@ export default function SongBookImport() { title="Import Song" subtitle="Paste a tab or fetch one from a URL, review, then save" actions={( - - - SongBook - + <> + + + SongBook + + {/* The form's own Save sits below a tall textarea + preview — on a + phone that is ~1000px down. This header copy keeps the primary + action above the fold from any scroll position (#6001). */} + + )} /> diff --git a/client/src/pages/SongBookImport.test.jsx b/client/src/pages/SongBookImport.test.jsx index 5db634fff2..fd8595ef7a 100644 --- a/client/src/pages/SongBookImport.test.jsx +++ b/client/src/pages/SongBookImport.test.jsx @@ -49,6 +49,24 @@ describe('SongBookImport', () => { expect(body.content.text).toBe('< C G Am'); }); + it('the header Save action saves the draft and is gated on content (#6001)', async () => { + // The form's own Save sits below the textarea + preview — off-screen on a + // phone — so the header copy is the one that has to work above the fold. + clipboard.readClipboard.mockResolvedValue('C G Am'); + renderPage(); + const headerSave = screen.getByRole('button', { name: 'Save' }); + expect(headerSave.disabled).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: 'Paste' })); + await screen.findByLabelText('Pasted tab content'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' }).disabled).toBe(false)); + + fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Example Song' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(api.createSong).toHaveBeenCalledTimes(1)); + expect(api.createSong.mock.calls[0][0].title).toBe('Example Song'); + }); + it('clamps ChordPro meta before sending: out-of-range capo dropped, long key sliced to 20', async () => { const sheet = '{key: ThisKeyNameIsWayTooLongForTheSchema}\n{capo: 13}\nC G Am\nInvented lyric line'; renderPage();