From 3d9b210154070f4fea7f8c04f0ca97675c05c28f Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 20 Jun 2026 00:23:10 +0100 Subject: [PATCH] Add bulk character/group assignment to script bulk edit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing bulk act/scene modal into a full BulkEditModal supporting character and character group assignment across a line range. Users can now select a target part index (1–4) and assign a character or character group to that part for all lines in the bulk range; lines without the targeted part are silently skipped. Both act/scene and character assignment sections are always visible and independently applied based on whether their values are filled in. Implemented in both client-v3 and client (Vue 2 legacy) with parity. Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/tests/10-show-config-script.spec.ts | 91 +++++- .../show/config/script/BulkEditModal.vue | 247 +++++++++++++++++ .../show/config/script/ScriptEditor.vue | 44 ++- .../show/config/script/BulkEditModal.vue | 262 ++++++++++++++++++ .../show/config/script/ScriptEditor.vue | 54 +++- 5 files changed, 674 insertions(+), 24 deletions(-) create mode 100644 client-v3/src/components/show/config/script/BulkEditModal.vue create mode 100644 client/src/vue_components/show/config/script/BulkEditModal.vue diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts index f942e51f..068f8b80 100644 --- a/client-v3/e2e/tests/10-show-config-script.spec.ts +++ b/client-v3/e2e/tests/10-show-config-script.spec.ts @@ -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 }); @@ -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 ────────────────────────────────────────────────────────────── @@ -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) @@ -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({ @@ -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 }); }); @@ -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 }); }); diff --git a/client-v3/src/components/show/config/script/BulkEditModal.vue b/client-v3/src/components/show/config/script/BulkEditModal.vue new file mode 100644 index 00000000..1e47215a --- /dev/null +++ b/client-v3/src/components/show/config/script/BulkEditModal.vue @@ -0,0 +1,247 @@ + + + diff --git a/client-v3/src/components/show/config/script/ScriptEditor.vue b/client-v3/src/components/show/config/script/ScriptEditor.vue index ce862e05..88a28ef7 100644 --- a/client-v3/src/components/show/config/script/ScriptEditor.vue +++ b/client-v3/src/components/show/config/script/ScriptEditor.vue @@ -161,12 +161,14 @@ /> - @@ -211,7 +213,7 @@ import { useConfirm } from '@/composables/useConfirm'; import { toast } from '@/js/toast'; import ScriptLineEditor from './ScriptLineEditor.vue'; import ScriptLineViewer from './ScriptLineViewer.vue'; -import BulkActSceneModal from './BulkActSceneModal.vue'; +import BulkEditModal from './BulkEditModal.vue'; import type { ScriptLine } from '@/types/api/script'; const MRU_LOOK_BACK = 4; @@ -247,7 +249,7 @@ const pageInputNo = ref(1); const saveModal = ref>(); const goToPageModal = ref>(); -const bulkModal = ref>(); +const bulkModal = ref>(); const currentPageLines = computed(() => scriptConfigStore.getTmpPage(currentPage.value)); const deletedLines = computed(() => scriptConfigStore.getDeletedLines(currentPage.value)); @@ -712,11 +714,22 @@ function endLines_isLast(lines: ScriptLine[], index: number): boolean { return index === lines.length - 1; } -async function onBulkApply({ actId, sceneId }: { actId: number; sceneId: number }): Promise { +async function onBulkApply(payload: { + actId: number | null; + sceneId: number | null; + partIndex: number | null; + characterId: number | null; + characterGroupId: number | null; +}): Promise { if (!bulkEditStart.value || !bulkEditEnd.value) return; const { page: startPage, lineIndex: startIndex } = bulkEditStart.value; const { page: endPage, lineIndex: endIndex } = bulkEditEnd.value; + const applyActScene = payload.actId != null && payload.sceneId != null; + const applyCharacter = + payload.partIndex != null && (payload.characterId != null || payload.characterGroupId != null); + const targetPartIdx = (payload.partIndex ?? 1) - 1; + for (let p = startPage; p <= endPage; p++) { await loadPage(p); const pageLines = scriptConfigStore.getTmpPage(p); @@ -725,7 +738,28 @@ async function onBulkApply({ actId, sceneId }: { actId: number; sceneId: number const deletedOnPage = scriptConfigStore.getDeletedLines(p); for (let i = fromIdx; i <= toIdx; i++) { if (deletedOnPage.includes(i)) continue; - scriptConfigStore.setLine(p, i, { ...pageLines[i], act_id: actId, scene_id: sceneId }); + const updatedLine = { ...pageLines[i] }; + if (applyActScene) { + updatedLine.act_id = payload.actId!; + updatedLine.scene_id = payload.sceneId!; + } + if (applyCharacter) { + const sortedParts = [...updatedLine.line_parts].sort( + (a, b) => (a.part_index ?? 0) - (b.part_index ?? 0) + ); + if (sortedParts.length > targetPartIdx) { + updatedLine.line_parts = sortedParts.map((part, idx) => + idx === targetPartIdx + ? { + ...part, + character_id: payload.characterId, + character_group_id: payload.characterGroupId, + } + : part + ); + } + } + scriptConfigStore.setLine(p, i, updatedLine); } } diff --git a/client/src/vue_components/show/config/script/BulkEditModal.vue b/client/src/vue_components/show/config/script/BulkEditModal.vue new file mode 100644 index 00000000..e5d96fcb --- /dev/null +++ b/client/src/vue_components/show/config/script/BulkEditModal.vue @@ -0,0 +1,262 @@ + + + diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index 9945b5ab..740d9b1b 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -167,11 +167,13 @@ /> - { if (val != null) { await this.loadBoundaryLines(); - (this as any).$bvModal.show('bulk-act-scene-modal'); + (this as any).$bvModal.show('bulk-edit-modal'); } }, USER_SETTINGS(): void { @@ -1045,10 +1047,22 @@ export default defineComponent({ this.nextLineOfEnd = nextPage ? nextPage[0] : null; } }, - async onBulkApply({ actId, sceneId }: { actId: number; sceneId: number }): Promise { + async onBulkApply(payload: { + actId: number | null; + sceneId: number | null; + partIndex: number | null; + characterId: number | null; + characterGroupId: number | null; + }): Promise { const { page: startPage, lineIndex: startIndex } = this.bulkEditStart!; const { page: endPage, lineIndex: endIndex } = this.bulkEditEnd!; + const applyActScene = payload.actId != null && payload.sceneId != null; + const applyCharacter = + payload.partIndex != null && + (payload.characterId != null || payload.characterGroupId != null); + const targetPartIdx = (payload.partIndex ?? 1) - 1; + for (let p = startPage; p <= endPage; p++) { await (this as any).LOAD_SCRIPT_PAGE(p); } @@ -1060,15 +1074,33 @@ export default defineComponent({ const toIndex = p === endPage ? endIndex : pageLines.length - 1; for (let i = fromIndex; i <= toIndex; i++) { if ((this as any).DELETED_LINES(p).includes(i)) continue; - (this as any).SET_LINE({ - pageNo: p, - lineIndex: i, - lineObj: { ...pageLines[i], act_id: actId, scene_id: sceneId }, - }); + const line = pageLines[i]; + const updatedLine: any = { ...line }; + if (applyActScene) { + updatedLine.act_id = payload.actId; + updatedLine.scene_id = payload.sceneId; + } + if (applyCharacter) { + const sortedParts = [...(line.line_parts ?? [])].sort( + (a: any, b: any) => (a.part_index ?? 0) - (b.part_index ?? 0) + ); + if (sortedParts.length > targetPartIdx) { + updatedLine.line_parts = sortedParts.map((part: any, idx: number) => + idx === targetPartIdx + ? { + ...part, + character_id: payload.characterId, + character_group_id: payload.characterGroupId, + } + : part + ); + } + } + (this as any).SET_LINE({ pageNo: p, lineIndex: i, lineObj: updatedLine }); } } - (this as any).$bvModal.hide('bulk-act-scene-modal'); + (this as any).$bvModal.hide('bulk-edit-modal'); this.exitBulkEditMode(); }, ...mapMutations([