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
51 changes: 51 additions & 0 deletions client-v3/e2e/tests/06-show-config-characters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
waitForAppReady,
waitForModal,
confirmModal,
cancelModal,
waitForModalClosed,
confirmDialog,
} from '../helpers.js';
Expand Down Expand Up @@ -82,6 +83,56 @@ test('deletes a character', async () => {
});
});

// ── Character Merge ────────────────────────────────────────────────────────

test('Merge button is visible for each character row', async () => {
const row = page.locator('tr', { has: page.locator('td:has-text("Hamlet")') });
await expect(row.locator('button:has-text("Merge")')).toBeVisible();
});

test('creates a character for merge testing', async () => {
await page.getByRole('button', { name: 'New Character', exact: true }).click();
await waitForModal(page, 'New Character');
await page.fill('.modal.show input[type="text"]', 'Horatio');
await confirmModal(page);
await waitForModalClosed(page);
await expect(page.locator('td:has-text("Horatio")').first()).toBeVisible();
});

test('merge modal opens with correct title', async () => {
const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') });
await row.locator('button:has-text("Merge")').click();
await waitForModal(page, /Merge Horatio/);
await cancelModal(page);
await waitForModalClosed(page);
});

test('merge OK button is disabled with no destination selected', async () => {
const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') });
await row.locator('button:has-text("Merge")').click();
await waitForModal(page, /Merge Horatio/);
const okBtn = page.locator('.modal.show .modal-footer button.btn-primary');
await expect(okBtn).toBeDisabled();
await cancelModal(page);
await waitForModalClosed(page);
});

test('merges a character into another', async () => {
const row = page.locator('tr', { has: page.locator('td:has-text("Horatio")') });
await row.locator('button:has-text("Merge")').click();
await waitForModal(page, /Merge Horatio/);
// Open the dropdown by clicking the multiselect container, then select the option.
await page.locator('.modal.show .multiselect').click();
await page.locator('.modal.show .multiselect__option', { hasText: 'Hamlet' }).click();
await confirmModal(page);
await waitForModalClosed(page);
// Scope to the character table to avoid matching cells in the Line Counts tab (always in DOM).
await expect(page.locator('#character-table td:has-text("Horatio")')).not.toBeVisible({
timeout: 5_000,
});
await expect(page.locator('#character-table td:has-text("Hamlet")')).toBeVisible();
});

// ── Character Groups ──────────────────────────────────────────────────────

test('switches to Character Groups sub-tab', async () => {
Expand Down
16 changes: 16 additions & 0 deletions client-v3/src/stores/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,22 @@ export const useShowStore = defineStore('show', {
}
},

async mergeCharacter(sourceId: number, destinationId: number): Promise<void> {
const response = await fetch(makeURL('/api/v1/show/character/merge'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id: sourceId, destination_id: destinationId }),
});
if (response.ok) {
// getCharacterGroupList calls getCharacterList internally
await this.getCharacterGroupList();
toast.success('Merged character!');
} else {
log.error('Unable to merge characters');
toast.error('Unable to merge characters');
}
},

// Character Groups
async getCharacterGroupList(): Promise<void> {
const response = await fetch(makeURL('/api/v1/show/character/group'));
Expand Down
81 changes: 79 additions & 2 deletions client-v3/src/views/show/config/ConfigCharacters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,21 @@
<BButtonGroup v-if="systemStore.isShowEditor">
<BButton
variant="warning"
:disabled="submittingEditCharacter || deletingCharacter"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="openEditForm(data.item)"
>
Edit
</BButton>
<BButton
variant="info"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="openMergeForm(data.item)"
>
Merge
</BButton>
<BButton
variant="danger"
:disabled="submittingEditCharacter || deletingCharacter"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="deleteCharacter(data.item)"
>
Delete
Expand Down Expand Up @@ -99,6 +106,33 @@
</BForm>
</BModal>

<BModal
id="merge-character"
ref="mergeCharacterModal"
:title="`Merge ${mergeSourceCharacter?.name ?? 'Character'}`"
size="md"
:ok-disabled="!mergeDestinationObject || mergingCharacter"
@hide="resetMergeForm"
@ok="onSubmitMerge"
>
<p>
Select a destination character. All script lines and group memberships from
<strong>{{ mergeSourceCharacter?.name }}</strong> will be transferred to the selected
character, and <strong>{{ mergeSourceCharacter?.name }}</strong> will be deleted.
</p>
<BFormGroup label="Merge into" label-for="merge-destination-input" label-cols="4">
<VueMultiselect
id="merge-destination-input"
v-model="mergeDestinationObject"
:multiple="false"
:options="mergeDestinationOptions"
track-by="id"
label="name"
placeholder="Select destination character"
/>
</BFormGroup>
</BModal>

<BModal
id="edit-character"
ref="editCharacterModal"
Expand Down Expand Up @@ -138,6 +172,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { BModal } from 'bootstrap-vue-next';
import log from 'loglevel';
import VueMultiselect from 'vue-multiselect';
import 'vue-multiselect/dist/vue-multiselect.css';
import { useSystemStore } from '@/stores/system';
import { useShowStore } from '@/stores/show';
import { useConfirm } from '@/composables/useConfirm';
Expand All @@ -157,6 +193,11 @@ const deletingCharacter = ref(false);

const newCharacterModal = ref<InstanceType<typeof BModal>>();
const editCharacterModal = ref<InstanceType<typeof BModal>>();
const mergeCharacterModal = ref<InstanceType<typeof BModal>>();

const mergingCharacter = ref(false);
const mergeSourceCharacter = ref<Character | null>(null);
const mergeDestinationObject = ref<Character | null>(null);

const characterFields = [
'name',
Expand Down Expand Up @@ -194,6 +235,12 @@ const editRules = { editFormState: { name: { required } } };
const newV$ = useVuelidate(newRules, { newFormState });
const editV$ = useVuelidate(editRules, { editFormState });

const mergeDestinationOptions = computed(() =>
showStore.characterList.filter(
(c) => mergeSourceCharacter.value === null || c.id !== mergeSourceCharacter.value.id
)
);

const castOptions = computed(() => [
{ value: null, text: 'Please select an option', disabled: true },
...showStore.castList.map((c) => ({
Expand Down Expand Up @@ -274,6 +321,36 @@ async function onSubmitEdit(event: Event): Promise<void> {
}
}

function openMergeForm(character: Character): void {
mergeSourceCharacter.value = character;
mergeDestinationObject.value = null;
mergeCharacterModal.value?.show();
}

function resetMergeForm(): void {
mergeSourceCharacter.value = null;
mergeDestinationObject.value = null;
mergingCharacter.value = false;
}

async function onSubmitMerge(event: Event): Promise<void> {
if (!mergeDestinationObject.value || mergingCharacter.value) {
event.preventDefault();
return;
}
mergingCharacter.value = true;
try {
await showStore.mergeCharacter(mergeSourceCharacter.value!.id, mergeDestinationObject.value.id);
mergeCharacterModal.value?.hide();
resetMergeForm();
} catch (error) {
log.error('Error merging character:', error);
event.preventDefault();
} finally {
mergingCharacter.value = false;
}
}

async function deleteCharacter(character: Character): Promise<void> {
if (deletingCharacter.value) return;
const ok = await confirm(`Are you sure you want to delete ${character.name}?`);
Expand Down
17 changes: 17 additions & 0 deletions client/src/store/modules/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ const module: Module<ShowState, RootState> = {
VueToast.$toast.error('Unable to delete character');
}
},
async MERGE_CHARACTER(
context,
{ source_id, destination_id }: { source_id: number; destination_id: number }
) {
const response = await fetch(makeURL('/api/v1/show/character/merge'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_id, destination_id }),
});
if (response.ok) {
context.dispatch('GET_CHARACTER_GROUP_LIST');
VueToast.$toast.success('Merged character!');
} else {
log.error('Unable to merge characters');
VueToast.$toast.error('Unable to merge characters');
}
},
async UPDATE_CHARACTER(context, character: Partial<Character>) {
const response = await fetch(`${makeURL('/api/v1/show/character')}`, {
method: 'PATCH',
Expand Down
77 changes: 75 additions & 2 deletions client/src/views/show/config/ConfigCharacters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,21 @@
<b-button-group v-if="IS_SHOW_EDITOR">
<b-button
variant="warning"
:disabled="submittingEditCharacter || deletingCharacter"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="openEditForm(data)"
>
Edit
</b-button>
<b-button
variant="info"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="openMergeForm(data)"
>
Merge
</b-button>
<b-button
variant="danger"
:disabled="submittingEditCharacter || deletingCharacter"
:disabled="submittingEditCharacter || deletingCharacter || mergingCharacter"
@click="deleteCharacter(data)"
>
Delete
Expand All @@ -62,6 +69,32 @@
</b-tabs>
</b-col>
</b-row>
<b-modal
id="merge-character"
ref="merge-character"
:title="`Merge ${mergeSourceCharacter ? mergeSourceCharacter.name : 'Character'}`"
size="md"
:ok-disabled="!mergeDestinationCharacter || mergingCharacter"
@hidden="resetMergeForm"
@ok="onSubmitMerge"
>
<p>
Select a destination character. All script lines and group memberships from
<strong>{{ mergeSourceCharacter ? mergeSourceCharacter.name : '' }}</strong>
will be transferred to the selected character, and it will be deleted.
</p>
<b-form-group label="Merge into" label-for="merge-destination-input" label-cols="4">
<multi-select
id="merge-destination-input"
v-model="mergeDestinationCharacter"
:multiple="false"
:options="mergeDestinationOptions"
track-by="id"
label="name"
placeholder="Select destination character"
/>
</b-form-group>
</b-modal>
<b-modal
id="new-character"
ref="new-character"
Expand Down Expand Up @@ -200,6 +233,9 @@ export default defineComponent({
submittingNewCharacter: false,
submittingEditCharacter: false,
deletingCharacter: false,
mergingCharacter: false,
mergeSourceCharacter: null as any,
mergeDestinationCharacter: null as any,
};
},
validations: {
Expand All @@ -225,6 +261,12 @@ export default defineComponent({
})),
];
},
mergeDestinationOptions(): any[] {
return (this as any).CHARACTER_LIST.filter(
(c: any) =>
!(this as any).mergeSourceCharacter || c.id !== (this as any).mergeSourceCharacter.id
);
},
},
async mounted(): Promise<void> {
await Promise.all([(this as any).GET_CHARACTER_LIST(), (this as any).GET_CAST_LIST()]);
Expand Down Expand Up @@ -308,12 +350,43 @@ export default defineComponent({
}
}
},
openMergeForm(character: any): void {
this.mergeSourceCharacter = character.item;
this.mergeDestinationCharacter = null;
(this as any).$bvModal.show('merge-character');
},
resetMergeForm(): void {
this.mergeSourceCharacter = null;
this.mergeDestinationCharacter = null;
this.mergingCharacter = false;
},
async onSubmitMerge(event: Event): Promise<void> {
if (!this.mergeDestinationCharacter || this.mergingCharacter) {
event.preventDefault();
return;
}
this.mergingCharacter = true;
try {
await (this as any).MERGE_CHARACTER({
source_id: this.mergeSourceCharacter.id,
destination_id: this.mergeDestinationCharacter.id,
});
(this as any).$bvModal.hide('merge-character');
this.resetMergeForm();
} catch (error) {
log.error('Error merging character:', error);
event.preventDefault();
} finally {
this.mergingCharacter = false;
}
},
...mapActions([
'GET_CHARACTER_LIST',
'GET_CAST_LIST',
'ADD_CHARACTER',
'UPDATE_CHARACTER',
'DELETE_CHARACTER',
'MERGE_CHARACTER',
]),
},
});
Expand Down
3 changes: 3 additions & 0 deletions server/controllers/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@

ERROR_NAME_ALREADY_TAKEN = "Name already taken"
ERROR_TAG_NAME_EXISTS = "Tag name already exists (case-insensitive)"
ERROR_CANNOT_MERGE_SAME_CHARACTER = (
"Source and destination characters must be different"
)
Loading
Loading