Files
ontime/e2e/tests/features/209-rundown-shortcuts.spec.ts
T
Claude a779f96f69 feat(finder): search all text fields and add filter badges
Searching only matched titles, so an event was unreachable by its cue,
its note, or any custom field value. Custom fields are how teams model
their own show data, which made the most valuable data the least
findable.

A bare query now matches cue, title, note and text custom fields across
events, groups and milestones, and each result names the field it
matched with an excerpt, so a hit in a note is legible. Widening the
scan is free: at 5000 entries a pass over every field measures the same
as the previous title-only pass, both far below the render cost.

The filter syntax was only discoverable through a line of footer text.
It is now a row of badges built from the fixed fields plus the project
custom fields, which scope the search while keeping whatever the user
already typed. The input becomes controlled, which removes the effect
that replayed the last search on rundown changes.

Raises the result cap and reports the total, since matching more fields
means more results than the previous cap could show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzALEq9gGWwFmwTdgAiFcY
2026-08-18 12:23:00 +00:00

475 lines
22 KiB
TypeScript

import { expect, test } from '@playwright/test';
test('Copy-paste', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
// create event
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByLabel('Cue', { exact: true }).click();
await page.getByLabel('Cue', { exact: true }).fill('4');
await page.getByLabel('Cue', { exact: true }).press('Enter');
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').click();
await page.getByTestId('entry__title').fill('test');
await page.getByTestId('entry__title').press('Enter');
// copy paste below
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).click();
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('ControlOrMeta+c');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '4' }).press('ControlOrMeta+v');
// assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
});
test('Cut-paste', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').getByTestId('entry__title').click();
await page.getByTestId('entry-1').getByTestId('entry__title').fill('first');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByTestId('entry-2').getByTestId('entry__title').click();
await page.getByTestId('entry-2').getByTestId('entry__title').fill('second');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
// cut first event, paste below second
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
await page.getByTestId('entry-1').getByTestId('rundown-event').filter({ hasText: '1' }).press('ControlOrMeta+x');
await page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2').click();
await page.getByTestId('entry-2').getByTestId('rundown-event').filter({ hasText: '2' }).press('ControlOrMeta+v');
// we can verify that the entries have swapped places
const events = await page.getByTestId('entry__title').all();
await expect(events[0]).toHaveValue('second');
await expect(events[1]).toHaveValue('first');
});
test('Move', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
// copy move down
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
await page
.getByTestId('entry-1')
.getByTestId('rundown-event')
.filter({ hasText: '1' })
.press('Alt+Control+ArrowDown');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
// move entry three up twice, waiting for each reorder before targeting its new row
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
await page
.getByTestId('entry-3')
.getByTestId('rundown-event')
.filter({ hasText: '3' })
.press('Alt+ControlOrMeta+ArrowUp');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
await page
.getByTestId('entry-2')
.getByTestId('rundown-event')
.filter({ hasText: '3' })
.press('Alt+ControlOrMeta+ArrowUp');
await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('3');
});
test('Add group', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByTestId('rundown-group')).toHaveCount(0);
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-group')).toHaveCount(0);
await page.getByPlaceholder(/event title/i).fill('test');
await page.getByTestId('entry-1').click();
// add group below
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+G');
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-group')).toHaveCount(1);
await page.getByTestId('rundown-group').getByTestId('entry__title').fill('group below');
// add group above
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+G');
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-group')).toHaveCount(2);
await page.getByTestId('entry__title').first().fill('group above');
await expect(page.getByTestId(/entry__title/i).first()).toHaveValue('group above');
await expect(page.getByTestId(/entry__title/i).nth(2)).toHaveValue('group below');
await expect(page.getByTestId('entry-1').getByTestId(/entry__title/)).toHaveValue('test');
});
test('Add delay', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByTestId('rundown-delay')).toHaveCount(0);
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-delay')).toHaveCount(0);
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').press('Escape');
// add delay below
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+D');
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-delay')).toHaveCount(1);
await expect(page.getByTestId('delay-input')).toBeVisible();
// add delay above
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+D');
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await expect(page.getByTestId('rundown-delay')).toHaveCount(2);
await expect(page.getByTestId('entry-0').getByTestId('delay-input')).toBeVisible();
});
test('Add event', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create events
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').press('Escape');
// add event below
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+E');
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await expect(page.getByTestId('entry-2').getByTestId('rundown-event').getByText('2')).toBeVisible();
// add event above
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+E');
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('1');
});
test('Delete event', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.goto('/rundown');
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create event
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
// delete event
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).click();
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Backspace');
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Create Group' })).toBeVisible();
});
test('Find in rundown', async ({ page }) => {
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
});
test('Close finder with the search shortcut', async ({ page }) => {
await page.goto('/rundown');
await expect(page.getByTestId('panel-rundown')).toBeVisible();
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// the shortcut has to close the finder while the caret is in the search field
await page.getByPlaceholder('Search...').press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
});
test('Finder navigates to the clicked result', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create three events which all match the same search
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
await page.getByTestId('entry-3').getByTestId('entry__title').fill('finder three');
await page.getByTestId('entry-3').getByTestId('entry__title').press('Enter');
await page.keyboard.press('ControlOrMeta+f');
await page.getByPlaceholder('Search...').fill('finder');
await expect(page.getByTestId('finder-result')).toHaveCount(3);
/**
* Dispatch the click without moving the pointer first, which is what a touch device does.
* The finder used to submit whichever row was highlighted rather than the one being clicked.
*/
await page.getByTestId('finder-result').nth(2).dispatchEvent('click');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-3').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'false');
});
test('Finder navigates to the result picked with the keyboard', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// create two events which both match the same search
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('finder one');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
await page.getByTestId('entry-2').getByTestId('entry__title').fill('finder two');
await page.getByTestId('entry-2').getByTestId('entry__title').press('Enter');
// deliberately fired while the caret is still in a title field: the shortcut must not be
// swallowed by the input the user happens to be editing
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
await page.getByPlaceholder('Search...').fill('finder');
await expect(page.getByTestId('finder-result')).toHaveCount(2);
// the first result is highlighted on open, so one step down lands on the second
await page.getByPlaceholder('Search...').press('ArrowDown');
await expect(page.getByTestId('finder-result').nth(1)).toHaveAttribute('data-selected', 'true');
await page.getByPlaceholder('Search...').press('Enter');
await expect(page.getByPlaceholder('Search...')).toBeHidden();
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toHaveAttribute('data-selected', 'true');
});
test('Finder searches milestones by cue', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
// an event, and a milestone carrying its own cue
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByTestId('entry__title').press('Escape');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+M');
await expect(page.getByTestId('rundown-milestone')).toHaveCount(1);
await page.getByTestId('rundown-milestone').getByPlaceholder('Cue').fill('MILE9');
await page.getByTestId('rundown-milestone').getByPlaceholder('Cue').press('Enter');
await page.getByTestId('rundown-milestone').getByPlaceholder('Title').fill('zebrafish');
await page.getByTestId('rundown-milestone').getByPlaceholder('Title').press('Enter');
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// milestones were previously skipped by the cue search even though they carry a cue
await page.getByPlaceholder('Search...').fill('cue MILE9');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
// and remain findable by title
await page.getByPlaceholder('Search...').fill('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
});
test('Finder searches notes and reports the matching field', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('opening remarks');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
// the note is not shown on the rundown row, so it can only be reached by searching
await page.getByTestId('entry-1').click();
await page.getByLabel('Note', { exact: true }).fill('remember the zebrafish tank');
await page.getByLabel('Note', { exact: true }).press('Tab');
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// a bare query now reaches the note, and the row explains which field matched
await page.getByPlaceholder('Search...').fill('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-result-match')).toContainText('Note');
await expect(page.getByTestId('finder-result-match')).toContainText('zebrafish');
// scoping to the title excludes it again
await page.getByPlaceholder('Search...').fill('title zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(0);
});
test('Finder filter badges scope the search', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('sound check');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// typing first, then narrowing with a badge, keeps the search text
await page.getByPlaceholder('Search...').fill('sound');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
const filters = page.getByTestId('finder-filters');
await filters.getByRole('button', { name: 'Note', exact: true }).click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('note sound');
await expect(page.getByTestId('finder-result')).toHaveCount(0);
// switching to another badge replaces the filter rather than stacking
await filters.getByRole('button', { name: 'Title', exact: true }).click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('title sound');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-count')).toContainText('1 result');
// custom fields defined by the project are offered as filters too
await expect(filters.getByRole('button', { name: 'Song', exact: true })).toBeVisible();
await expect(filters.getByRole('button', { name: 'Artist', exact: true })).toBeVisible();
});
test('Finder searches custom fields', async ({ page }) => {
await page.goto('/rundown');
await page.getByRole('button', { name: 'Edit' }).click();
// clear rundown
await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await page.getByRole('button', { name: 'Create Event' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
await page.getByTestId('entry-1').getByTestId('entry__title').fill('interval');
await page.getByTestId('entry-1').getByTestId('entry__title').press('Enter');
// custom field values are not shown on the rundown row
await page.getByTestId('entry-1').click();
await page.getByLabel('Artist', { exact: true }).fill('zebrafish collective');
await page.getByLabel('Artist', { exact: true }).press('Tab');
await page.keyboard.press('ControlOrMeta+f');
await expect(page.getByPlaceholder('Search...')).toBeVisible();
// a bare query reaches custom field values, naming the field that matched
await page.getByPlaceholder('Search...').fill('zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
await expect(page.getByTestId('finder-result-match')).toContainText('Artist');
// and the field can be scoped explicitly
await page.getByTestId('finder-filters').getByRole('button', { name: 'Artist', exact: true }).click();
await expect(page.getByPlaceholder('Search...')).toHaveValue('Artist zebrafish');
await expect(page.getByTestId('finder-result')).toHaveCount(1);
});
test('Open settings', async ({ page }) => {
await page.goto('/editor');
await expect(page.getByTestId('editor-container')).toBeVisible();
await page.keyboard.press('ControlOrMeta+,');
await expect(page.getByRole('button', { name: 'Close settings' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('button', { name: 'Close settings' })).toBeHidden();
});