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
91 changes: 83 additions & 8 deletions client-v3/e2e/tests/10-show-config-script.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,31 @@ test('saves a dialogue line to the script', async () => {
await lineEditor.locator('select').nth(0).selectOption({ label: 'Act 1' });
await lineEditor.locator('select').nth(1).selectOption({ label: 'Scene 1' });

await page.locator('select').filter({ hasText: 'Hamlet' }).selectOption({ label: 'Hamlet' });
await lineEditor
.locator('select')
.filter({ hasText: 'Hamlet' })
.selectOption({ label: 'Hamlet' });
await page.locator('input[type="text"]:visible').last().fill('To be or not to be');

await page.locator('button:has-text("Done")').first().click();
// ScriptEditor auto-adds a blank dialogue line after Done — delete it before asserting
// ScriptEditor auto-adds a blank dialogue editor after Done — fill it in as a second line so
// the bulk edit tests below can use a two-line range without needing the split dropdown.
await expect(page.locator('button:has-text("Done")').first()).toBeVisible({ timeout: 5_000 });

const lineEditor2 = page
.locator('div.row')
.filter({ has: page.locator('button:has-text("Done")') })
.first();
await lineEditor2.locator('select').nth(0).selectOption({ label: 'Act 1' });
await lineEditor2.locator('select').nth(1).selectOption({ label: 'Scene 1' });
await lineEditor2
.locator('select')
.filter({ hasText: 'Hamlet' })
.selectOption({ label: 'Hamlet' });
await page.locator('input[type="text"]:visible').last().fill('That is the question');

await page.locator('button:has-text("Done")').first().click();
// Delete the third auto-added blank editor
await page.locator('button.btn-danger:has-text("Delete")').first().click();
await expect(page.locator('button:has-text("Done")')).not.toBeVisible({ timeout: 5_000 });

Expand All @@ -118,6 +138,59 @@ test('saves a dialogue line to the script', async () => {
await expect(
page.locator('.viewable-line').filter({ hasText: 'To be or not to be' })
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('.viewable-line').filter({ hasText: 'That is the question' })
).toBeVisible({ timeout: 10_000 });
});

// ── Bulk edit ─────────────────────────────────────────────────────────────

test('bulk edit mode is accessible and shows Start/End buttons', async () => {
// Still in edit mode from the save test, which saved two viewable dialogue lines.
await page.click('button:has-text("Bulk Edit")');
// Two ScriptLineViewer rows → two Start/End button pairs
await expect(page.getByRole('button', { name: 'Start', exact: true }).first()).toBeVisible({
timeout: 5_000,
});
await expect(page.getByRole('button', { name: 'End', exact: true }).first()).toBeVisible({
timeout: 5_000,
});
});

test('bulk edit opens the Bulk Edit modal when start and end span two different lines', async () => {
// Start on the first line, End on the last line (different indices → valid range)
await page.getByRole('button', { name: 'Start', exact: true }).first().click();
await page.getByRole('button', { name: 'End', exact: true }).last().click();
await waitForModal(page, 'Bulk Edit');
});

test('bulk edit can assign a character to part 1', async () => {
await page.locator('.modal.show #bulk-part-input').selectOption({ label: 'Part 1' });
// Select Alice — a different character from Hamlet (who is already assigned) so the apply
// produces a real change that scriptChanges can detect via deep equality.
await page.locator('.modal.show #bulk-char-input').selectOption({ label: 'Alice' });
await confirmModal(page);
await waitForModalClosed(page);
// After apply, bulk edit mode exits automatically
await expect(page.getByRole('button', { name: 'Bulk Edit', exact: true })).toBeVisible({
timeout: 5_000,
});
});

test('bulk edit stops when Exit Bulk Edit is clicked', async () => {
await page.click('button:has-text("Bulk Edit")');
await expect(page.getByRole('button', { name: 'Start', exact: true }).first()).toBeVisible({
timeout: 5_000,
});
await page.click('button:has-text("Exit Bulk Edit")');
await expect(page.getByRole('button', { name: 'Start', exact: true })).not.toBeVisible({
timeout: 3_000,
});
await page.click('button:has-text("Stop Editing")');
await confirmDialog(page);
await expect(page.getByRole('button', { name: 'Edit', exact: true })).toBeVisible({
timeout: 10_000,
});
});

// ── Cut mode ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -232,7 +305,7 @@ test('adds a cue to the script line', async () => {
// Scope to the visible modal's select to avoid matching the hidden "Add Cue Type" modal
// dialog which BVN assigns id="new-cue-type" via its auto-ID scheme.
await page.locator('.modal.show select#new-cue-type').selectOption({ index: 1 });
await page.fill('#new-cue-ident', '001');
await page.locator('.modal.show #new-cue-ident').fill('001');
await confirmModal(page);
await waitForModalClosed(page);
// Wait for the actual cue button (not the add-cue-btn which shares the cue-button class)
Expand All @@ -246,7 +319,7 @@ test('edits the cue identifier', async () => {
// Use :not(.add-cue-btn) to target the real cue button, not the add button
await page.locator('.cue-button:not(.add-cue-btn)').first().click();
await waitForModal(page, 'Edit Cue');
await page.fill('#edit-cue-ident', '002');
await page.locator('.modal.show #edit-cue-ident').fill('002');
await confirmModal(page);
await waitForModalClosed(page);
await expect(page.locator('.cue-button:not(.add-cue-btn)').first()).toBeVisible({
Expand All @@ -258,9 +331,11 @@ test('can add a cue using Enter key in Add New Cue modal', async () => {
await page.locator('.add-cue-btn').first().click();
await waitForModal(page, 'Add New Cue');
await page.locator('.modal.show select#new-cue-type').selectOption({ index: 1 });
await page.fill('#new-cue-ident', '003');
await page.locator('.modal.show #new-cue-ident').fill('003');
// Enter key submits the form (fix: BForm @submit bound to onSubmitNew)
await page.locator('#new-cue-ident').press('Enter');
// Scope to .modal.show to avoid strict-mode violation from other Add New Cue modal instances
// in the DOM (one per viewable script line — BVN v-show keeps them all present).
await page.locator('.modal.show #new-cue-ident').press('Enter');
await waitForModalClosed(page);
await expect(page.locator('.cue-button:not(.add-cue-btn)')).toHaveCount(2, { timeout: 5_000 });
});
Expand All @@ -269,9 +344,9 @@ test('can edit a cue identifier using Enter key in Edit Cue modal', async () =>
// Click the second cue button (003)
await page.locator('.cue-button:not(.add-cue-btn)').last().click();
await waitForModal(page, 'Edit Cue');
await page.fill('#edit-cue-ident', '004');
await page.locator('.modal.show #edit-cue-ident').fill('004');
// Enter key submits the form (fix: BForm @submit bound to onSubmitEdit)
await page.locator('#edit-cue-ident').press('Enter');
await page.locator('.modal.show #edit-cue-ident').press('Enter');
await waitForModalClosed(page);
await expect(page.locator('.cue-button:not(.add-cue-btn)')).toHaveCount(2, { timeout: 5_000 });
});
Expand Down
247 changes: 247 additions & 0 deletions client-v3/src/components/show/config/script/BulkEditModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<template>
<BModal
ref="modal"
title="Bulk Edit"
size="md"
ok-title="Apply"
:ok-disabled="!canApply"
@ok.prevent="handleOk"
@hidden="reset"
>
<p class="text-muted small mb-3">
Applies the selected changes to all lines from the chosen start line to end line (inclusive).
Fill in a section to apply those changes; leave it empty to skip it.
</p>

<h6 class="mb-2">Act / Scene</h6>
<BFormGroup label="Act" label-for="bulk-act-input">
<BFormSelect
id="bulk-act-input"
v-model="selectedActId"
:options="actOptions"
@update:model-value="selectedSceneId = null"
/>
</BFormGroup>
<BFormGroup label="Scene" label-for="bulk-scene-input">
<BFormSelect
id="bulk-scene-input"
v-model="selectedSceneId"
:options="sceneOptions"
:disabled="selectedActId == null"
/>
</BFormGroup>

<hr />

<h6 class="mb-2">Character Assignment</h6>
<p class="text-muted small mb-2">Lines without the selected part will be skipped.</p>
<BFormGroup label="Part" label-for="bulk-part-input">
<BFormSelect id="bulk-part-input" v-model="selectedPartIndex" :options="partOptions" />
</BFormGroup>
<template v-if="combinedDropdown">
<BFormGroup label="Character / Character Group" label-for="bulk-char-combined-input">
<BFormSelect
id="bulk-char-combined-input"
v-model="combinedValue"
:options="combinedOptions"
:disabled="selectedPartIndex == null"
/>
</BFormGroup>
</template>
<template v-else>
<BFormGroup
v-show="selectedCharacterGroupId == null"
label="Character"
label-for="bulk-char-input"
>
<BFormSelect
id="bulk-char-input"
v-model="selectedCharacterId"
:options="characterOptions"
:disabled="selectedPartIndex == null"
/>
</BFormGroup>
<BFormGroup
v-show="selectedCharacterId == null"
label="Character Group"
label-for="bulk-char-group-input"
>
<BFormSelect
id="bulk-char-group-input"
v-model="selectedCharacterGroupId"
:options="characterGroupOptions"
:disabled="selectedPartIndex == null"
/>
</BFormGroup>
</template>
</BModal>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import type { BModal } from 'bootstrap-vue-next';
import type { ScriptLine } from '@/types/api/script';
import type { Act, Scene, Character, CharacterGroup } from '@/types/api/show';
import { useUserStore } from '@/stores/user';

const props = defineProps<{
previousLineOfStart: ScriptLine | null;
nextLineOfEnd: ScriptLine | null;
acts: Act[];
scenes: Scene[];
characters: Character[];
characterGroups: CharacterGroup[];
}>();

const emit = defineEmits<{
apply: [
payload: {
actId: number | null;
sceneId: number | null;
partIndex: number | null;
characterId: number | null;
characterGroupId: number | null;
},
];
}>();

const userStore = useUserStore();
const modal = ref<InstanceType<typeof BModal>>();

const selectedActId = ref<number | null>(null);
const selectedSceneId = ref<number | null>(null);
const selectedPartIndex = ref<number | null>(null);
const selectedCharacterId = ref<number | null>(null);
const selectedCharacterGroupId = ref<number | null>(null);

const combinedDropdown = computed(
() =>
!!(userStore.userSettings as { character_combined_dropdown?: boolean })
.character_combined_dropdown
);

const validActs = computed<Act[]>(() => {
let startAct = props.acts.find((a) => a.previous_act == null) ?? null;
if (props.previousLineOfStart?.act_id != null) {
startAct = props.acts.find((a) => a.id === props.previousLineOfStart!.act_id) ?? startAct;
}
const result: Act[] = [];
let cur = startAct;
while (cur) {
result.push(cur);
if (props.nextLineOfEnd && props.nextLineOfEnd.act_id === cur.id) break;
cur = cur.next_act == null ? null : (props.acts.find((a) => a.id === cur!.next_act) ?? null);
}
return result;
});

const validScenes = computed<Scene[]>(() => {
if (selectedActId.value == null) return [];
const actScenes = props.scenes.filter((s) => s.act === selectedActId.value);
let startScene = actScenes.find((s) => s.previous_scene == null) ?? null;
if (
props.previousLineOfStart?.act_id === selectedActId.value &&
props.previousLineOfStart?.scene_id != null
) {
startScene = actScenes.find((s) => s.id === props.previousLineOfStart!.scene_id) ?? startScene;
}
const result: Scene[] = [];
let cur = startScene;
while (cur) {
result.push(cur);
if (props.nextLineOfEnd && props.nextLineOfEnd.scene_id === cur.id) break;
cur = cur.next_scene == null ? null : (actScenes.find((s) => s.id === cur!.next_scene) ?? null);
}
return result;
});

const actOptions = computed(() => [
{ value: null, text: 'Select an act', disabled: true },
...validActs.value.map((a) => ({ value: a.id, text: a.name })),
]);

const sceneOptions = computed(() => [
{ value: null, text: 'Select a scene', disabled: true },
...validScenes.value.map((s) => ({ value: s.id, text: s.name })),
]);

const partOptions = computed(() => [
{ value: null, text: 'Select a part', disabled: true },
{ value: 1, text: 'Part 1' },
{ value: 2, text: 'Part 2' },
{ value: 3, text: 'Part 3' },
{ value: 4, text: 'Part 4' },
]);

const characterOptions = computed(() => [
{ value: null, text: 'N/A' },
...props.characters.map((c) => ({ value: c.id, text: c.name })),
]);

const characterGroupOptions = computed(() => [
{ value: null, text: 'N/A' },
...props.characterGroups.map((g) => ({ value: g.id, text: g.name })),
]);

const combinedOptions = computed<{ value: string | null; text: string; disabled?: boolean }[]>(
() => [
{ value: null, text: 'Select character / group', disabled: true },
...props.characters.map((c) => ({ value: `c:${c.id}`, text: c.name ?? '' })),
...props.characterGroups.map((g) => ({ value: `g:${g.id}`, text: g.name ?? '' })),
]
);

const combinedValue = computed<string | null>({
get() {
if (selectedCharacterId.value != null) return `c:${selectedCharacterId.value}`;
if (selectedCharacterGroupId.value != null) return `g:${selectedCharacterGroupId.value}`;
return null;
},
set(val: string | null) {
if (val == null) {
selectedCharacterId.value = null;
selectedCharacterGroupId.value = null;
} else if (val.startsWith('c:')) {
selectedCharacterId.value = Number.parseInt(val.slice(2), 10);
selectedCharacterGroupId.value = null;
} else if (val.startsWith('g:')) {
selectedCharacterId.value = null;
selectedCharacterGroupId.value = Number.parseInt(val.slice(2), 10);
}
},
});

const actSceneComplete = computed(
() => selectedActId.value != null && selectedSceneId.value != null
);

const characterComplete = computed(
() =>
selectedPartIndex.value != null &&
(selectedCharacterId.value != null || selectedCharacterGroupId.value != null)
);

const canApply = computed(() => actSceneComplete.value || characterComplete.value);

function handleOk(): void {
if (canApply.value) {
emit('apply', {
actId: actSceneComplete.value ? selectedActId.value : null,
sceneId: actSceneComplete.value ? selectedSceneId.value : null,
partIndex: characterComplete.value ? selectedPartIndex.value : null,
characterId: characterComplete.value ? selectedCharacterId.value : null,
characterGroupId: characterComplete.value ? selectedCharacterGroupId.value : null,
});
}
}

function reset(): void {
selectedActId.value = null;
selectedSceneId.value = null;
selectedPartIndex.value = null;
selectedCharacterId.value = null;
selectedCharacterGroupId.value = null;
}

defineExpose({ show: () => modal.value?.show(), hide: () => modal.value?.hide() });
</script>
Loading
Loading