mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
bdf5767a5f
The rail lists its sections in the order a book is made: Pages, Content, Layouts, Elements and Travel, then Theme and Layers set apart below a rule. Stickers are a tab of Elements beside the icons instead of a rail section of their own, and Elements has six tabs (text, shapes, lines, frames with the grids, icons, stickers). The quotation mark moved to the text styles, the outline rectangle to the basic shapes and the plain rule to the lines. The rail and every tab row are tab lists the arrow keys walk through, and a long name wraps instead of being cut. The icon and sticker searches say in the field how many match, which the sticker library's own head used to. Every panel on both sides opens on the same head, and every labelled control uses one row that moves the control under its label when the two do not fit, so no label sits in a fixed column any more. Number fields, chips, sliders, tab labels, theme names and travel cards wrap the same way, a country beside a stop wraps under itself, and an upload cell holds its words to two lines with the whole label in its tooltip. The heads, shelves and groups are real headings, so a screen reader can walk the panels by them. The inspector shows every kind in one order: what the element is and says, its position and size, its look, its effects, then Arrange. Align and the stacking buttons are one Arrange group with a button that opens the page in Layers; its rows sit under their labels, each in two sets that move to a second line whole. Rarely used groups start folded and open on an element that already uses them (a turned element, a map held at a zoom, a summary set in another face), and every group mounts afresh for the next selection. A sticker's duplicate angle and opacity are gone in favour of the shared position group, which now has the turn buttons for every kind. With several elements selected it keeps their angle, opacity and turn, and the mirror when all of them are stickers, and leaves the frame's numbers to one element. A photo's fill page and fill spread sit under its crop. With nothing selected the inspector shows the page background, the book's theme with the way to the Theme panel, and the page numbers. Seventeen translation keys nothing has used since before the rebuild (the old inspector title, the empty spread note, the summary and country list names, the roads buttons and others) are gone from every language. The broad key template the old panels used had counted them as reached; the format names, the empty photo grid messages and the map source hints are now named where the key check can read them. Also fixed on the way: the travel stops list shared its class with the gradient stops and was laid out as sixteen pixel handles; the alignment buttons were named in raw English; the map's print resolution note joined its two halves with a dash.
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import type { BookDocument, BookPageSetup } from '@trek/shared'
|
|
import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'
|
|
import { fireEvent, render, screen, waitFor, within } from '../../helpers/render'
|
|
import { StudioSidebar, type JourneySource } from '../../../src/components/Studio/StudioSidebar'
|
|
import type { StudioUploader } from '../../../src/components/Studio/studioUpload'
|
|
import { useStudioStore } from '../../../src/store/studioStore'
|
|
|
|
/**
|
|
* Content > Photos, narrowed to a day, and the upload cell (#2064).
|
|
*
|
|
* A journey of three hundred pictures showed them as one grid, so laying out
|
|
* the page for one day meant scrolling past every other day to find its
|
|
* pictures. What these check is that the browser can be narrowed to one
|
|
* entry, to the pictures no entry claims, and to what just arrived; that the
|
|
* search still applies on top; and that a picture uploaded here lands where
|
|
* the panel says it will, the entry the filter names or the gallery when it
|
|
* names none.
|
|
*/
|
|
|
|
const page: BookPageSetup = bookPageSetupSchema.parse({
|
|
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
|
|
})
|
|
|
|
type Entry = JourneySource['entries'][number]
|
|
|
|
const entry = (over: Partial<Entry>): Entry => ({
|
|
id: 1, title: 'Reykjavik', story: null, location: null, date: '2026-06-01',
|
|
lat: null, lng: null, mood: null, weather: null, pros: [], cons: [], photoIds: [],
|
|
...over,
|
|
})
|
|
|
|
/** Two days with pictures, one written day without, and one picture only the gallery holds. */
|
|
const source: JourneySource = {
|
|
entries: [
|
|
entry({ id: 1, title: 'Reykjavik', date: '2026-06-01', photoIds: [11, 12] }),
|
|
entry({ id: 2, title: 'Vik', location: 'Vik i Myrdal', date: '2026-06-02', photoIds: [13] }),
|
|
entry({ id: 3, title: 'A written day', date: '2026-06-03' }),
|
|
],
|
|
photos: [
|
|
{ photoId: 11, caption: null, entryIds: [1] },
|
|
{ photoId: 12, caption: 'Harbour', entryIds: [1] },
|
|
{ photoId: 13, caption: null, entryIds: [2] },
|
|
{ photoId: 14, caption: 'Loose', entryIds: [] },
|
|
],
|
|
photoEntries: { 11: 'reykjavik', 12: 'reykjavik', 13: 'vik vik i myrdal' },
|
|
}
|
|
|
|
/** One inner spread, tied to a day or to none. */
|
|
const book = (entryId: number | null = null): BookDocument => normalizeBookDocument({
|
|
version: 1,
|
|
title: 'Iceland',
|
|
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
|
|
spreads: [{ id: 'sp1', role: 'inner', entryId, background: null, elements: [], parked: [] }],
|
|
})
|
|
|
|
const nothing: StudioUploader = async () => ({ photoIds: [], failed: 0, skippedVideos: 0 })
|
|
|
|
beforeEach(() => {
|
|
useStudioStore.getState().load(book())
|
|
})
|
|
|
|
/** Render the sidebar and open Content, which lands on the Photos tab. */
|
|
function openContent(src = source, over: { canEdit?: boolean; onUpload?: StudioUploader } = {}) {
|
|
const view = render(
|
|
<StudioSidebar
|
|
page={page}
|
|
pxPerMm={96 / 25.4}
|
|
bookView={false}
|
|
source={src}
|
|
stats={null}
|
|
path={[]}
|
|
t={(k: string) => k}
|
|
locale="en-US"
|
|
canEdit={over.canEdit ?? true}
|
|
onUpload={over.onUpload ?? nothing}
|
|
onToggleStop={async () => true}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
|
return view
|
|
}
|
|
|
|
/** The pictures the grid shows now, by id, in grid order. */
|
|
const shown = () => [...document.querySelectorAll('.st-photo-cell:not(.is-upload) img')]
|
|
.map(img => Number(img.getAttribute('src')!.match(/\/photos\/(\d+)\//)![1]))
|
|
|
|
const openMenu = () => fireEvent.click(screen.getByTitle('journey.studio.filterPhotos'))
|
|
|
|
const chipLabel = () => document.querySelector('.st-filter-name')!.textContent
|
|
|
|
const picture = (name: string) => new File(['x'], name, { type: 'image/jpeg' })
|
|
|
|
describe('the filter menu', () => {
|
|
it('lists every entry that has pictures, with how many', () => {
|
|
openContent()
|
|
openMenu()
|
|
|
|
const items = screen.getAllByRole('menuitemradio').map(b => b.textContent)
|
|
expect(items[0]).toContain('journey.studio.filterAll')
|
|
expect(items[0]).toContain('4')
|
|
expect(items[1]).toContain('journey.studio.filterLoose')
|
|
expect(items[1]).toContain('1')
|
|
expect(items.find(s => s!.includes('Reykjavik'))).toContain('2')
|
|
expect(items.find(s => s!.includes('Vik i Myrdal'))).toContain('1')
|
|
// A day with words and no pictures is not a filter worth offering.
|
|
expect(items.some(s => s!.includes('A written day'))).toBe(false)
|
|
})
|
|
|
|
it('narrows the grid to one day, and the cross widens it again', () => {
|
|
openContent()
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
|
|
|
|
expect(shown()).toEqual([11, 12])
|
|
expect(chipLabel()).toBe('Reykjavik')
|
|
|
|
fireEvent.click(screen.getByLabelText('common.clear'))
|
|
|
|
expect(shown()).toEqual([11, 12, 13, 14])
|
|
expect(chipLabel()).toBe('journey.studio.filterAll')
|
|
})
|
|
|
|
it('shows only the pictures no entry claims', () => {
|
|
openContent()
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ }))
|
|
|
|
expect(shown()).toEqual([14])
|
|
})
|
|
|
|
it('combines with the search rather than replacing it', () => {
|
|
openContent()
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
|
|
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } })
|
|
|
|
expect(shown()).toEqual([12])
|
|
})
|
|
|
|
/* Four reasons for an empty grid, each said in its own words. */
|
|
it('says why the grid is empty', () => {
|
|
openContent()
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
|
|
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'myrdal' } })
|
|
expect(screen.getByText('journey.studio.noMatches')).toBeInTheDocument()
|
|
})
|
|
|
|
it('says so when every picture belongs to a day', () => {
|
|
openContent({ ...source, photos: source.photos.filter(p => p.entryIds.length) })
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /journey\.studio\.filterLoose/ }))
|
|
|
|
expect(shown()).toEqual([])
|
|
expect(screen.getByText('journey.studio.noLoosePhotos')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('the way in from an entry', () => {
|
|
it('opens the pictures of the day from its card', () => {
|
|
openContent()
|
|
fireEvent.click(screen.getByRole('tab', { name: /journey\.studio\.entries/ }))
|
|
|
|
const card = screen.getByText('Reykjavik').closest('.st-entry') as HTMLElement
|
|
fireEvent.click(within(card).getByText('journey.studio.entryPhotos'))
|
|
|
|
expect(shown()).toEqual([11, 12])
|
|
expect(chipLabel()).toBe('Reykjavik')
|
|
})
|
|
|
|
it('offers no such chip on a day without pictures', () => {
|
|
openContent()
|
|
fireEvent.click(screen.getByRole('tab', { name: /journey\.studio\.entries/ }))
|
|
|
|
const card = screen.getByText('A written day').closest('.st-entry') as HTMLElement
|
|
expect(within(card).queryByText('journey.studio.entryPhotos')).toBeNull()
|
|
})
|
|
|
|
it('offers the page\'s own day while the filter is not on it', () => {
|
|
useStudioStore.getState().load(book(1))
|
|
openContent()
|
|
|
|
fireEvent.click(screen.getByText('journey.studio.filterThisPage'))
|
|
|
|
expect(shown()).toEqual([11, 12])
|
|
expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull()
|
|
})
|
|
|
|
it('offers nothing for a page tied to a day without pictures', () => {
|
|
useStudioStore.getState().load(book(3))
|
|
openContent()
|
|
expect(screen.queryByText('journey.studio.filterThisPage')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('the upload cell', () => {
|
|
/* A square a third of the panel wide holds its words to two lines; the tooltip has the rest. */
|
|
it('names itself in full in its title, ahead of what it does', () => {
|
|
openContent()
|
|
|
|
const cell = document.querySelector('.st-photo-cell.is-upload')
|
|
expect(cell?.getAttribute('title')).toBe('journey.studio.uploadPhotos\njourney.studio.uploadHint')
|
|
expect(cell?.querySelector('span')?.textContent).toBe('journey.studio.uploadPhotos')
|
|
})
|
|
|
|
it('is not there for a viewer', () => {
|
|
openContent(source, { canEdit: false })
|
|
|
|
expect(document.querySelector('.st-photo-cell.is-upload')).toBeNull()
|
|
expect(document.querySelector('input[type="file"]')).toBeNull()
|
|
expect(screen.queryByText('journey.studio.uploadToGallery')).toBeNull()
|
|
})
|
|
|
|
it('sends into the gallery when no day is chosen', async () => {
|
|
const onUpload = vi.fn(nothing)
|
|
openContent(source, { onUpload })
|
|
expect(screen.getByText('journey.studio.uploadToGallery')).toBeInTheDocument()
|
|
|
|
const file = picture('a.jpg')
|
|
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
|
|
|
|
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function)))
|
|
})
|
|
|
|
it('sends into the day the filter names', async () => {
|
|
const onUpload = vi.fn(nothing)
|
|
openContent(source, { onUpload })
|
|
openMenu()
|
|
fireEvent.click(screen.getByRole('menuitemradio', { name: /Reykjavik/ }))
|
|
expect(screen.getByText('journey.studio.uploadToEntry')).toBeInTheDocument()
|
|
|
|
const file = picture('b.jpg')
|
|
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
|
|
|
|
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], 1, expect.any(Function)))
|
|
})
|
|
|
|
/*
|
|
* The gallery sorts what arrives to its end, out of sight under three
|
|
* hundred pictures; the filter is what brings it into view. The source
|
|
* here already holds the new id, as the store would once the upload landed.
|
|
*/
|
|
it('shows what just arrived once it has', async () => {
|
|
const onUpload = vi.fn<StudioUploader>(async () => ({ photoIds: [99], failed: 0, skippedVideos: 0 }))
|
|
const arrived: JourneySource = {
|
|
...source,
|
|
photos: [...source.photos, { photoId: 99, caption: null, entryIds: [] }],
|
|
}
|
|
openContent(arrived, { onUpload })
|
|
fireEvent.change(screen.getByLabelText('journey.studio.searchContent'), { target: { value: 'harbour' } })
|
|
|
|
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [picture('c.jpg')] } })
|
|
|
|
await waitFor(() => expect(chipLabel()).toBe('journey.studio.filterRecent'))
|
|
expect(shown()).toEqual([99])
|
|
// The search is let go of too, or the new picture would be hidden by it.
|
|
expect((screen.getByLabelText('journey.studio.searchContent') as HTMLInputElement).value).toBe('')
|
|
})
|
|
|
|
it('takes a drop anywhere on the panel', async () => {
|
|
const onUpload = vi.fn(nothing)
|
|
openContent(source, { onUpload })
|
|
|
|
const file = picture('d.jpg')
|
|
fireEvent.drop(document.querySelector('.st-panel-scroll')!, {
|
|
dataTransfer: { files: [file], types: ['Files'] },
|
|
})
|
|
|
|
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([file], null, expect.any(Function)))
|
|
})
|
|
})
|
|
|
|
describe('photos from a provider (#2271)', () => {
|
|
it('offers one cell per connected provider, asks with the filtered entry, and shows what landed', async () => {
|
|
const onBrowseProvider = vi.fn(async () => [14])
|
|
render(
|
|
<StudioSidebar
|
|
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
|
|
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
|
|
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
|
fireEvent.click(screen.getByTitle(/journey\.studio\.fromProviderHint/))
|
|
expect(onBrowseProvider).toHaveBeenCalledWith('immich', null)
|
|
await waitFor(() => expect(shown()).toEqual([14]))
|
|
})
|
|
|
|
it('a closed picker changes nothing, and a viewer gets no provider cell', async () => {
|
|
const onBrowseProvider = vi.fn(async () => [] as number[])
|
|
const { unmount } = render(
|
|
<StudioSidebar
|
|
page={page} pxPerMm={96 / 25.4} bookView={false} source={source} stats={null} path={[]}
|
|
t={(k: string) => k} locale="en-US" canEdit onUpload={nothing} onToggleStop={async () => true}
|
|
providers={[{ id: 'immich', name: 'Immich' }]} onBrowseProvider={onBrowseProvider}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByLabelText('journey.studio.content'))
|
|
const before = shown()
|
|
fireEvent.click(screen.getByTitle(/journey\.studio\.fromProviderHint/))
|
|
await waitFor(() => expect(onBrowseProvider).toHaveBeenCalled())
|
|
expect(shown()).toEqual(before)
|
|
unmount()
|
|
|
|
openContent(source, { canEdit: false })
|
|
expect(screen.queryByTitle(/journey\.studio\.fromProviderHint/)).toBeNull()
|
|
})
|
|
})
|
|
|