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
26 changes: 21 additions & 5 deletions client/src/pages/SongBookImport.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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={(
<Link to="/songbook" className={btnClass}>
<ArrowLeft size={15} />
SongBook
</Link>
<>
<Link to="/songbook" className={btnClass}>
<ArrowLeft size={15} />
<span className="hidden sm:inline">SongBook</span>
</Link>
{/* 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). */}
<button
type="button"
onClick={() => save()}
disabled={saving || !contentText.trim()}
title={saveHint || undefined}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-port-accent text-white hover:bg-port-accent/90 disabled:opacity-50"
>
<Save size={15} />
{saving ? 'Saving…' : 'Save'}
</button>
</>
)}
/>

Expand Down
18 changes: 18 additions & 0 deletions client/src/pages/SongBookImport.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ describe('SongBookImport', () => {
expect(body.content.text).toBe('&lt; 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();
Expand Down