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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"editor.formatOnSave": true,
"conventionalCommits.scopes": [
"common-ui",
"single-language-tracklist"
"single-language-tracklist",
"expand-events"
],
"js/ts.tsdk.path": "node_modules/typescript/lib",
"css.lint.validProperties": [
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ See the script [README](scripts/single-language-tracklist/README.md) for more in
[![install][badge-install]](https://github.com/dvirtz/musicbrainz-scripts/releases/latest/download/scaffold-festival-days.user.js) [![beta][badge-beta]](https://github.com/dvirtz/musicbrainz-scripts/releases/download/beta-latest/scaffold-festival-days.user.js)
[![source][badge-source]](scripts/scaffold-festival-days/src/index.ts)

Scaffolds day, venue, and single-day place sub-events for festival event hierarchies.
Scaffolds and stage sub-events for festival event hierarchies.

![toolbox](scripts/scaffold-festival-days/assets/toolbox.png)

Expand All @@ -72,7 +72,7 @@ See the script [README](scripts/scaffold-festival-days/README.md) for more infor
[![install][badge-install]](https://github.com/dvirtz/musicbrainz-scripts/releases/latest/download/add-sub-event.user.js) [![beta][badge-beta]](https://github.com/dvirtz/musicbrainz-scripts/releases/download/beta-latest/add-sub-event.user.js)
[![source][badge-source]](scripts/add-sub-event/src/index.ts)

Adds an `Add sub-event` link on event pages and opens a prefilled sub-event create form.
Adds an `Add sub-event` link on event pages and seeds an event page with parent event details and relationships.

See the script [README](scripts/add-sub-event/README.md) for more information.

Expand Down
2 changes: 2 additions & 0 deletions lib/musicbrainz-ext/src/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface MBEvent {
name: string;
type?: string;
'type-id'?: string;
time?: string;
cancelled?: boolean;
'life-span'?: {
begin?: string;
Expand Down Expand Up @@ -33,6 +34,7 @@ export interface MBEvent {
name?: string;
type?: string;
'type-id'?: string;
time?: string;
'life-span'?: {
begin?: string;
end?: string;
Expand Down
36 changes: 35 additions & 1 deletion scripts/expand-events/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ChildEventSummary = {
type?: string;
beginDate?: string;
endDate?: string;
time?: string;
};

export type EventDetails = {
Expand All @@ -19,6 +20,7 @@ export type EventDetails = {
status?: string;
beginDate?: string;
endDate?: string;
time?: string;
places: string[];
seedData: ParentEventSeedData;
childEvents: ChildEventSummary[];
Expand Down Expand Up @@ -48,6 +50,24 @@ function formatStatus(event: MBEvent): string | undefined {
return undefined;
}

function normalizeTime(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

function compareOptionalStrings(a?: string, b?: string): number {
if (!a && !b) {
return 0;
}
if (!a) {
return 1;
}
if (!b) {
return -1;
}
return a.localeCompare(b, undefined, {sensitivity: 'base'});
}

function normalizePlaceLabel(relation: MBEventRelation): string | null {
if (relation['target-type'] !== 'place') {
return null;
Expand Down Expand Up @@ -84,10 +104,23 @@ function parseChildEvents(relations: ReadonlyArray<MBEventRelation>): ChildEvent
type: relation.event?.type,
beginDate: relation.event?.['life-span']?.begin,
endDate: relation.event?.['life-span']?.end,
time: normalizeTime(relation.event?.time),
});
}

return children;
return children.sort((a, b) => {
const byDate = compareOptionalStrings(a.beginDate, b.beginDate);
if (byDate !== 0) {
return byDate;
}

const byTime = compareOptionalStrings(a.time, b.time);
if (byTime !== 0) {
return byTime;
}

return a.name.localeCompare(b.name, undefined, {sensitivity: 'base'});
});
}

export async function fetchEventDetails(eventGid: string): Promise<EventDetails | null> {
Expand Down Expand Up @@ -117,6 +150,7 @@ export async function fetchEventDetails(eventGid: string): Promise<EventDetails
status: formatStatus(event),
beginDate: event['life-span']?.begin,
endDate: event['life-span']?.end,
time: normalizeTime(event.time),
places: Array.from(places),
seedData,
childEvents: parseChildEvents(event.relations ?? []),
Expand Down
4 changes: 4 additions & 0 deletions scripts/expand-events/src/ui.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
width: 100px;
}

col.timeColumn {
width: 60px;
}

col.spacerColumn {
width: 20px;
}
Expand Down
30 changes: 25 additions & 5 deletions scripts/expand-events/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ function createTextCell(text: string) {
return cell;
}

function createOptionalTextCell(text: string | undefined) {
return createTextCell(text ?? '');
}

function formatDateRange(beginDate?: string, endDate?: string): string {
if (beginDate && endDate) {
return beginDate === endDate ? beginDate : `${beginDate} → ${endDate}`;
Expand All @@ -97,7 +101,7 @@ function createIndentedTable() {
table.className = `tbl ${classes.detailsTable}`;

const colgroup = document.createElement('colgroup');
const columnClasses = ['', classes.typeColumn, classes.dateColumn, classes.spacerColumn];
const columnClasses = ['', classes.typeColumn, classes.dateColumn, classes.timeColumn, classes.spacerColumn];
columnClasses.forEach(columnClass => {
const col = document.createElement('col');
if (columnClass) {
Expand Down Expand Up @@ -291,7 +295,7 @@ class EventToggle implements ToggleController {
table.appendChild(this.renderLeafSummaryRow(details));
}

table.appendChild(createQuickLinksRow(details, this.context, 4));
table.appendChild(createQuickLinksRow(details, this.context, 5));
return table;
}

Expand All @@ -306,8 +310,9 @@ class EventToggle implements ToggleController {
const link = createLink(`/event/${childEvent.gid}`, childEvent.name);
titleCell.appendChild(link);
row.appendChild(titleCell);
row.appendChild(createTextCell(childEvent.type!));
row.appendChild(createTextCell(''));
row.appendChild(createTextCell(formatDateRange(childEvent.beginDate, childEvent.endDate)));
row.appendChild(createOptionalTextCell(childEvent.time));
row.appendChild(createTextCell(''));
table.appendChild(row);

Expand All @@ -320,13 +325,28 @@ class EventToggle implements ToggleController {
this.childControllers.push(controller);
this.context.allControllers.add(controller);
}

// Pre-fetch event details for all child events to improve responsiveness
this.prefetchChildDetails(childEvents);
}

private prefetchChildDetails(childEvents: ChildEventSummary[]) {
for (const childEvent of childEvents) {
const existing = this.context.detailsCache.get(childEvent.gid);
if (!existing) {
const request = fetchEventDetails(childEvent.gid);
this.context.detailsCache.set(childEvent.gid, request);
}
}
}

private renderLeafSummaryRow(details: EventDetails) {
const row = document.createElement('tr');
row.appendChild(createTextCell(details.places.join(', ')));
row.appendChild(createTextCell(details.type!));
row.appendChild(createTextCell(formatDateRange(details.beginDate, details.endDate)));
row.appendChild(createTextCell(''));
const typeCell = createOptionalTextCell(details.type);
typeCell.colSpan = 2;
row.appendChild(typeCell);
row.appendChild(createTextCell(''));
return row;
}
Expand Down
148 changes: 121 additions & 27 deletions scripts/expand-events/tests/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,50 @@ import {expect, type Page} from '@playwright/test';
import {test} from '@repo/test-support/musicbrainz-test';

const LIVE_EVENT_GID = '6d5e8ba6-2e4c-44e6-b990-506eb50b4faa';
const LIVE_CHILD_EVENT_GID = '0d422c29-e2c7-4946-9d47-031c792f4fa8';
const LIVE_GRANDCHILD_EVENT_GID = 'e5f279e2-c595-40cf-9ccc-aa15356c4cb4';

type RealUserscriptPage = {
page: Page;
userscriptPath: string;
};

type ChildRowSummary = {
name: string;
date?: string;
time?: string;
};

function compareOptionalStrings(a?: string, b?: string): number {
if (!a && !b) {
return 0;
}
if (!a) {
return 1;
}
if (!b) {
return -1;
}
return a.localeCompare(b, undefined, {sensitivity: 'base'});
}

function compareChildRowSummary(a: ChildRowSummary, b: ChildRowSummary): number {
const byDate = compareOptionalStrings(a.date, b.date);
if (byDate !== 0) {
return byDate;
}

const byTime = compareOptionalStrings(a.time, b.time);
if (byTime !== 0) {
return byTime;
}

return a.name.localeCompare(b.name, undefined, {sensitivity: 'base'});
}

async function gotoLiveEvent(userscriptPage: RealUserscriptPage) {
const response = await userscriptPage.page.goto(`/event/${LIVE_EVENT_GID}`);
const status = response?.status() ?? 'unknown';
test.skip(!response?.ok(), `Live event /event/${LIVE_EVENT_GID} is unavailable (HTTP ${status}).`);
expect(response?.ok(), `Live event /event/${LIVE_EVENT_GID} should be available`).toBeTruthy();

await userscriptPage.page.waitForFunction(() => document.body !== null);
await userscriptPage.page.addScriptTag({path: userscriptPage.userscriptPath});
Expand All @@ -21,19 +55,48 @@ test.describe('expand-events', () => {
test('injects toggles on a real event page and shows quick links on expand', async ({userscriptPage, page}) => {
await gotoLiveEvent(userscriptPage);

const toggles = page.locator('.expand-events-toggle');
await expect(toggles.first()).toBeVisible();

const firstToggle = toggles.first();
const firstEventGid = await firstToggle.getAttribute('data-event-gid');
expect(firstEventGid).toBeTruthy();

await firstToggle.click();

const detailsRow = page.locator(`[data-expand-events-row-for="${firstEventGid}"]`);
await expect(detailsRow).not.toHaveAttribute('hidden', '');

const quickLinks = page.locator(`[data-expand-events-quick-links-for="${firstEventGid}"]`);
const childToggle = page.locator(`.expand-events-toggle[data-event-gid="${LIVE_CHILD_EVENT_GID}"]`).first();
await expect(childToggle).toBeVisible();
await childToggle.click();
await expect(page.locator(`[data-expand-events-row-for="${LIVE_CHILD_EVENT_GID}"]`)).not.toHaveAttribute(
'hidden',
''
);

const grandchildToggle = page
.locator(
`[data-expand-events-details-for="${LIVE_CHILD_EVENT_GID}"] .expand-events-toggle[data-event-gid="${LIVE_GRANDCHILD_EVENT_GID}"]`
)
.first();
await expect(grandchildToggle).toBeVisible();
await grandchildToggle.click();
await expect(page.locator(`[data-expand-events-row-for="${LIVE_GRANDCHILD_EVENT_GID}"]`)).not.toHaveAttribute(
'hidden',
''
);

await expect(page.locator(`[data-expand-events-quick-links-for="${LIVE_GRANDCHILD_EVENT_GID}"]`)).toBeVisible();

const childRows = page.locator(
`[data-expand-events-details-for="${LIVE_GRANDCHILD_EVENT_GID}"] tr:has(td a[href*="/event/"]):not(:has([data-expand-events-quick-links-for]))`
);
await expect(childRows).toHaveCount(3);

const actualOrder = await childRows.evaluateAll(rows =>
rows.map(row => {
const cells = row.querySelectorAll('td');
const link = cells[0]?.querySelector('a');
const name = link?.textContent?.trim() ?? '';
const date = cells[2]?.textContent?.trim() || undefined;
const time = cells[3]?.textContent?.trim() || undefined;
return {name, date, time};
})
);

const expectedOrder = [...actualOrder].sort(compareChildRowSummary);
expect(actualOrder).toEqual(expectedOrder);

const quickLinks = page.locator(`[data-expand-events-quick-links-for="${LIVE_GRANDCHILD_EVENT_GID}"]`);
await expect(quickLinks).toContainText('edit');
await expect(quickLinks).toContainText('editing history');
await expect(quickLinks).toContainText('add event art');
Expand Down Expand Up @@ -72,26 +135,57 @@ test.describe('expand-events', () => {
await expect(addSubEventQuickLink).toHaveAttribute('href', /\/event\/create\?/);
});

test('supports recursive expansion on a real event page', async ({userscriptPage, page}) => {
test('supports recursive expansion on a real event page and shows leaf metadata', async ({userscriptPage, page}) => {
await gotoLiveEvent(userscriptPage);

const firstTopLevelToggle = page.locator('.expand-events-toggle').first();
const firstEventGid = await firstTopLevelToggle.getAttribute('data-event-gid');
expect(firstEventGid).toBeTruthy();
const childToggle = page.locator(`.expand-events-toggle[data-event-gid="${LIVE_CHILD_EVENT_GID}"]`).first();
await expect(childToggle).toBeVisible();
await childToggle.click();
await expect(page.locator(`[data-expand-events-row-for="${LIVE_CHILD_EVENT_GID}"]`)).not.toHaveAttribute(
'hidden',
''
);

const grandchildToggle = page
.locator(
`[data-expand-events-details-for="${LIVE_CHILD_EVENT_GID}"] .expand-events-toggle[data-event-gid="${LIVE_GRANDCHILD_EVENT_GID}"]`
)
.first();
await expect(grandchildToggle).toBeVisible();
await grandchildToggle.click();

await firstTopLevelToggle.click();
await expect(page.locator(`[data-expand-events-row-for="${LIVE_GRANDCHILD_EVENT_GID}"]`)).not.toHaveAttribute(
'hidden',
''
);

const nestedToggle = page
.locator(`[data-expand-events-details-for="${firstEventGid}"] .expand-events-toggle`)
const childRows = page.locator(
`[data-expand-events-details-for="${LIVE_GRANDCHILD_EVENT_GID}"] tr:has(td a[href*="/event/"]):not(:has([data-expand-events-quick-links-for]))`
);
await expect(childRows).toHaveCount(3);

const firstLeafToggle = page
.locator(`[data-expand-events-details-for="${LIVE_GRANDCHILD_EVENT_GID}"] .expand-events-toggle`)
.first();
await expect(nestedToggle).toBeVisible();
await expect(firstLeafToggle).toBeVisible();

const firstLeafGid = await firstLeafToggle.getAttribute('data-event-gid');
expect(firstLeafGid).toBeTruthy();

const nestedEventGid = await nestedToggle.getAttribute('data-event-gid');
expect(nestedEventGid).toBeTruthy();
await firstLeafToggle.click();
await expect(page.locator(`[data-expand-events-row-for="${firstLeafGid}"]`)).not.toHaveAttribute('hidden', '');

await nestedToggle.click();
const leafRows = page.locator(
`[data-expand-events-details-for="${firstLeafGid}"] tr:not(:has([data-expand-events-quick-links-for]))`
);
await expect(leafRows.first()).toBeVisible();

await expect(page.locator(`[data-expand-events-row-for="${nestedEventGid}"]`)).not.toHaveAttribute('hidden', '');
const cells = leafRows.first().locator('td');
await expect(cells.nth(0)).not.toBeEmpty(); // place
await expect(cells.nth(1)).toBeEmpty(); // removed type column for leaves
await expect(cells.nth(2)).not.toBeEmpty(); // type
await expect(cells.nth(2)).toHaveAttribute('colspan', '2'); // spans date+time columns
await expect(cells.nth(3)).toBeEmpty(); // spacer column
});

test('expand all and collapse all work on the real event page', async ({userscriptPage, page}) => {
Expand Down
2 changes: 1 addition & 1 deletion scripts/scaffold-festival-days/src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function hasChildSubEvents(event: MBEvent): boolean {
}

export function shouldShowScaffoldUI(event: MBEvent | null): event is MBEvent {
return event != null && event.type === 'Festival' && deriveDates(event).length > 0 && !hasChildSubEvents(event);
return event != null && deriveDates(event).length > 0 && !hasChildSubEvents(event);
}

export function isSingleDayFestival(event: MBEvent): boolean {
Expand Down
Loading
Loading