This commit is contained in:
pa
2026-03-06 04:22:16 +09:00
parent 761ef5ad6b
commit 787f25705e
55 changed files with 6437 additions and 506 deletions

View File

@@ -38,7 +38,7 @@
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { useI18n } from 'vue-i18n';
import { copyToClipboard } from '../../../shared/utils';
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
const { t } = useI18n();
@@ -77,6 +77,11 @@
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
/**
*
* @param label
* @param checked
*/
function toggleExportOption(label, checked) {
const selection = checkedExportBansOptions.value;
const index = selection.indexOf(label);
@@ -88,6 +93,11 @@
updateExportContent();
}
/**
*
* @param item
* @param key
*/
function getRowValue(item, key) {
switch (key) {
case 'displayName':
@@ -101,9 +111,10 @@
}
}
/**
*
*/
function updateExportContent() {
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
const sortedCheckedOptions = exportBansOptions
.filter((option) => checkedExportBansOptions.value.includes(option.label))
.map((option) => option.label);
@@ -111,16 +122,22 @@
const header = `${sortedCheckedOptions.join(',')}\n`;
const content = props.groupBansModerationTable.data
.map((item) => sortedCheckedOptions.map((key) => formatter(String(getRowValue(item, key)))).join(','))
.map((item) => sortedCheckedOptions.map((key) => formatCsvField(String(getRowValue(item, key)))).join(','))
.join('\n');
exportContent.value = header + content;
}
/**
*
*/
function handleCopyExportContent() {
copyToClipboard(exportContent.value);
}
/**
*
*/
function setIsGroupBansExportDialogVisible() {
emit('update:isGroupBansExportDialogVisible', false);
}

View File

@@ -41,7 +41,7 @@
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { useI18n } from 'vue-i18n';
import { copyToClipboard } from '../../../shared/utils';
import { copyToClipboard, formatCsvField } from '../../../shared/utils';
const { t } = useI18n();
@@ -83,6 +83,11 @@
'data'
]);
/**
*
* @param label
* @param checked
*/
function toggleGroupLogsExportOption(label, checked) {
const selection = checkedGroupLogsExportLogsOptions.value;
const index = selection.indexOf(label);
@@ -94,9 +99,10 @@
updateGroupLogsExportContent();
}
/**
*
*/
function updateGroupLogsExportContent() {
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
const sortedCheckedOptions = checkGroupsLogsExportLogsOptions
.filter((option) => checkedGroupLogsExportLogsOptions.value.includes(option.label))
.map((option) => option.label);
@@ -106,18 +112,24 @@
const content = props.groupLogsModerationTable.data
.map((item) =>
sortedCheckedOptions
.map((key) => formatter(key === 'data' ? JSON.stringify(item[key]) : item[key]))
.map((key) => formatCsvField(key === 'data' ? JSON.stringify(item[key]) : item[key]))
.join(',')
)
.join('\n');
groupLogsExportContent.value = header + content; // Update ref
groupLogsExportContent.value = header + content;
}
/**
*
*/
function handleCopyGroupLogsExportContent() {
copyToClipboard(groupLogsExportContent.value);
}
/**
*
*/
function setIsGroupLogsExportDialogVisible() {
emit('update:isGroupLogsExportDialogVisible', false);
}

View File

@@ -0,0 +1,272 @@
import { ref } from 'vue';
import { describe, expect, test, vi } from 'vitest';
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { useGroupBatchOperations } from '../useGroupBatchOperations';
/**
*
* @param overrides
*/
function createDeps(overrides = {}) {
return {
selectedUsersArray: ref([
{
userId: 'usr_1',
displayName: 'Alice',
roleIds: ['role_1'],
managerNotes: ''
},
{
userId: 'usr_2',
displayName: 'Bob',
roleIds: ['role_1'],
managerNotes: ''
}
]),
currentUser: ref({ id: 'usr_self' }),
groupMemberModeration: ref({ id: 'grp_test' }),
deselectedUsers: vi.fn(),
groupRequest: {
banGroupMember: vi.fn().mockResolvedValue(undefined),
unbanGroupMember: vi.fn().mockResolvedValue(undefined),
kickGroupMember: vi.fn().mockResolvedValue(undefined),
setGroupMemberProps: vi.fn().mockResolvedValue(undefined),
removeGroupMemberRole: vi.fn().mockResolvedValue(undefined),
addGroupMemberRole: vi.fn().mockResolvedValue(undefined),
deleteSentGroupInvite: vi.fn().mockResolvedValue(undefined),
acceptGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
rejectGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
blockGroupInviteRequest: vi.fn().mockResolvedValue(undefined),
deleteBlockedGroupRequest: vi.fn().mockResolvedValue(undefined)
},
handleGroupMemberRoleChange: vi.fn(),
handleGroupMemberProps: vi.fn(),
...overrides
};
}
describe('useGroupBatchOperations', () => {
describe('runBatchOperation (via groupMembersBan)', () => {
test('calls action for each selected user', async () => {
const deps = createDeps();
const { groupMembersBan } = useGroupBatchOperations(deps);
await groupMembersBan();
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(2);
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
groupId: 'grp_test',
userId: 'usr_1'
});
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
groupId: 'grp_test',
userId: 'usr_2'
});
});
test('skips self user', async () => {
const deps = createDeps({
selectedUsersArray: ref([
{
userId: 'usr_self',
displayName: 'Self',
roleIds: [],
managerNotes: ''
},
{
userId: 'usr_1',
displayName: 'Alice',
roleIds: [],
managerNotes: ''
}
])
});
const { groupMembersBan } = useGroupBatchOperations(deps);
await groupMembersBan();
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(1);
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledWith({
groupId: 'grp_test',
userId: 'usr_1'
});
});
test('calls onComplete callback', async () => {
const deps = createDeps();
const onComplete = vi.fn();
const { groupMembersBan } = useGroupBatchOperations(deps);
await groupMembersBan({ onComplete });
expect(onComplete).toHaveBeenCalled();
});
test('handles errors gracefully', async () => {
const deps = createDeps();
deps.groupRequest.banGroupMember
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce(undefined);
const { groupMembersBan } = useGroupBatchOperations(deps);
await groupMembersBan();
// should still attempt the second user
expect(deps.groupRequest.banGroupMember).toHaveBeenCalledTimes(2);
});
test('tracks progress during operation', async () => {
const deps = createDeps();
const { groupMembersBan, progressTotal, progressCurrent } =
useGroupBatchOperations(deps);
expect(progressTotal.value).toBe(0);
const p = groupMembersBan();
await p;
// After completion, progress resets to 0
expect(progressTotal.value).toBe(0);
expect(progressCurrent.value).toBe(0);
});
});
describe('groupMembersUnban', () => {
test('calls unbanGroupMember for each user', async () => {
const deps = createDeps();
const { groupMembersUnban } = useGroupBatchOperations(deps);
await groupMembersUnban();
expect(deps.groupRequest.unbanGroupMember).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersKick', () => {
test('calls kickGroupMember for each user', async () => {
const deps = createDeps();
const { groupMembersKick } = useGroupBatchOperations(deps);
await groupMembersKick();
expect(deps.groupRequest.kickGroupMember).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersSaveNote', () => {
test('calls setGroupMemberProps with note value', async () => {
const deps = createDeps();
const { groupMembersSaveNote } = useGroupBatchOperations(deps);
await groupMembersSaveNote('Test note');
expect(deps.groupRequest.setGroupMemberProps).toHaveBeenCalledTimes(
2
);
expect(deps.groupRequest.setGroupMemberProps).toHaveBeenCalledWith(
'usr_1',
'grp_test',
{
managerNotes: 'Test note'
}
);
});
});
describe('groupMembersAddRoles', () => {
test('calls addGroupMemberRole for each role per user', async () => {
const deps = createDeps();
const { groupMembersAddRoles } = useGroupBatchOperations(deps);
await groupMembersAddRoles(['role_1', 'role_2']);
// Both users already have role_1, so only role_2 gets added → 2 calls
expect(deps.groupRequest.addGroupMemberRole).toHaveBeenCalledTimes(
2
);
});
});
describe('groupMembersRemoveRoles', () => {
test('calls removeGroupMemberRole for each role per user', async () => {
const deps = createDeps();
const { groupMembersRemoveRoles } = useGroupBatchOperations(deps);
await groupMembersRemoveRoles(['role_1']);
expect(
deps.groupRequest.removeGroupMemberRole
).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersDeleteSentInvite', () => {
test('calls deleteSentGroupInvite for each user', async () => {
const deps = createDeps();
const { groupMembersDeleteSentInvite } =
useGroupBatchOperations(deps);
await groupMembersDeleteSentInvite();
expect(
deps.groupRequest.deleteSentGroupInvite
).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersAcceptInviteRequest', () => {
test('calls acceptGroupInviteRequest for each user', async () => {
const deps = createDeps();
const { groupMembersAcceptInviteRequest } =
useGroupBatchOperations(deps);
await groupMembersAcceptInviteRequest();
expect(
deps.groupRequest.acceptGroupInviteRequest
).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersRejectInviteRequest', () => {
test('calls rejectGroupInviteRequest for each user', async () => {
const deps = createDeps();
const { groupMembersRejectInviteRequest } =
useGroupBatchOperations(deps);
await groupMembersRejectInviteRequest();
expect(
deps.groupRequest.rejectGroupInviteRequest
).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersBlockJoinRequest', () => {
test('calls blockGroupInviteRequest for each user', async () => {
const deps = createDeps();
const { groupMembersBlockJoinRequest } =
useGroupBatchOperations(deps);
await groupMembersBlockJoinRequest();
expect(
deps.groupRequest.blockGroupInviteRequest
).toHaveBeenCalledTimes(2);
});
});
describe('groupMembersDeleteBlockedRequest', () => {
test('calls deleteBlockedGroupRequest for each user', async () => {
const deps = createDeps();
const { groupMembersDeleteBlockedRequest } =
useGroupBatchOperations(deps);
await groupMembersDeleteBlockedRequest();
expect(
deps.groupRequest.deleteBlockedGroupRequest
).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,206 @@
import { describe, expect, test } from 'vitest';
import { useGroupModerationSelection } from '../useGroupModerationSelection';
function createTables() {
return {
members: { data: [] },
bans: { data: [] },
invites: { data: [] },
joinRequests: { data: [] },
blocked: { data: [] }
};
}
describe('useGroupModerationSelection', () => {
describe('setSelectedUsers', () => {
test('adds a user to selection', () => {
const tables = createTables();
const { selectedUsers, selectedUsersArray, setSelectedUsers } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
expect(selectedUsers['usr_1']).toEqual({
userId: 'usr_1',
name: 'Alice'
});
expect(selectedUsersArray.value).toHaveLength(1);
});
test('ignores null user', () => {
const tables = createTables();
const { selectedUsersArray, setSelectedUsers } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', null);
expect(selectedUsersArray.value).toHaveLength(0);
});
test('adds multiple users', () => {
const tables = createTables();
const { selectedUsersArray, setSelectedUsers } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
expect(selectedUsersArray.value).toHaveLength(2);
});
});
describe('deselectedUsers', () => {
test('removes a specific user', () => {
const tables = createTables();
const {
selectedUsers,
selectedUsersArray,
setSelectedUsers,
deselectedUsers
} = useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
deselectedUsers('usr_1');
expect(selectedUsers['usr_1']).toBeUndefined();
expect(selectedUsersArray.value).toHaveLength(1);
expect(selectedUsersArray.value[0].name).toBe('Bob');
});
test('removes all users when isAll=true', () => {
const tables = createTables();
const { selectedUsersArray, setSelectedUsers, deselectedUsers } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
setSelectedUsers('usr_2', { userId: 'usr_2', name: 'Bob' });
deselectedUsers(null, true);
expect(selectedUsersArray.value).toHaveLength(0);
});
});
describe('onSelectionChange', () => {
test('selects user when row.$selected is true', () => {
const tables = createTables();
const { selectedUsersArray, onSelectionChange } =
useGroupModerationSelection(tables);
onSelectionChange({
userId: 'usr_1',
name: 'Alice',
$selected: true
});
expect(selectedUsersArray.value).toHaveLength(1);
});
test('deselects user when row.$selected is false', () => {
const tables = createTables();
const { selectedUsersArray, setSelectedUsers, onSelectionChange } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
onSelectionChange({ userId: 'usr_1', $selected: false });
expect(selectedUsersArray.value).toHaveLength(0);
});
});
describe('deselectInTables', () => {
test('deselects specific user in table data', () => {
const tables = createTables();
tables.members.data = [
{ userId: 'usr_1', $selected: true },
{ userId: 'usr_2', $selected: true }
];
const { deselectInTables } = useGroupModerationSelection(tables);
deselectInTables('usr_1');
expect(tables.members.data[0].$selected).toBe(false);
expect(tables.members.data[1].$selected).toBe(true);
});
test('deselects all users when no userId', () => {
const tables = createTables();
tables.members.data = [
{ userId: 'usr_1', $selected: true },
{ userId: 'usr_2', $selected: true }
];
tables.bans.data = [{ userId: 'usr_3', $selected: true }];
const { deselectInTables } = useGroupModerationSelection(tables);
deselectInTables();
expect(tables.members.data[0].$selected).toBe(false);
expect(tables.members.data[1].$selected).toBe(false);
expect(tables.bans.data[0].$selected).toBe(false);
});
test('handles null table gracefully', () => {
const tables = createTables();
tables.members = null;
const { deselectInTables } = useGroupModerationSelection(tables);
expect(() => deselectInTables('usr_1')).not.toThrow();
});
});
describe('deleteSelectedUser', () => {
test('removes user from selection and tables', () => {
const tables = createTables();
tables.members.data = [{ userId: 'usr_1', $selected: true }];
const { selectedUsersArray, setSelectedUsers, deleteSelectedUser } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1', name: 'Alice' });
deleteSelectedUser({ userId: 'usr_1' });
expect(selectedUsersArray.value).toHaveLength(0);
expect(tables.members.data[0].$selected).toBe(false);
});
});
describe('clearAllSelected', () => {
test('clears all selections and table states', () => {
const tables = createTables();
tables.members.data = [
{ userId: 'usr_1', $selected: true },
{ userId: 'usr_2', $selected: true }
];
tables.bans.data = [{ userId: 'usr_3', $selected: true }];
const { selectedUsersArray, setSelectedUsers, clearAllSelected } =
useGroupModerationSelection(tables);
setSelectedUsers('usr_1', { userId: 'usr_1' });
setSelectedUsers('usr_2', { userId: 'usr_2' });
setSelectedUsers('usr_3', { userId: 'usr_3' });
clearAllSelected();
expect(selectedUsersArray.value).toHaveLength(0);
expect(tables.members.data.every((r) => !r.$selected)).toBe(true);
expect(tables.bans.data.every((r) => !r.$selected)).toBe(true);
});
});
describe('selectAll', () => {
test('selects all rows in a table', () => {
const tables = createTables();
const tableData = [
{ userId: 'usr_1', $selected: false },
{ userId: 'usr_2', $selected: false }
];
const { selectedUsersArray, selectAll } =
useGroupModerationSelection(tables);
selectAll(tableData);
expect(tableData.every((r) => r.$selected)).toBe(true);
expect(selectedUsersArray.value).toHaveLength(2);
});
});
});

View File

@@ -546,6 +546,7 @@
import { useI18n } from 'vue-i18n';
import {
buildLegacyInstanceTag,
copyToClipboard,
getLaunchURL,
hasGroupPermission,
@@ -690,6 +691,10 @@
return map;
});
/**
*
* @param userId
*/
function resolveUserDisplayName(userId) {
if (currentUser.value?.id && currentUser.value.id === userId) {
return currentUser.value.displayName;
@@ -742,6 +747,10 @@
return groups;
});
/**
*
* @param value
*/
function handleRoleIdsChange(value) {
const next = Array.isArray(value) ? value.map((v) => String(v ?? '')).filter(Boolean) : [];
newInstanceDialog.value.roleIds = next;
@@ -757,10 +766,17 @@
initializeNewInstanceDialog();
/**
*
*/
function closeInviteDialog() {
inviteDialog.value.visible = false;
}
/**
*
* @param tag
*/
function showInviteDialog(tag) {
if (!isRealInstance(tag)) {
return;
@@ -788,11 +804,20 @@
});
}
/**
*
* @param location
* @param shortName
*/
function handleAttachGame(location, shortName) {
tryOpenInstanceInVrc(location, shortName);
closeInviteDialog();
}
/**
*
* @param tag
*/
async function initNewInstanceDialog(tag) {
if (!isRealInstance(tag)) {
return;
@@ -823,6 +848,9 @@
updateNewInstanceDialog();
D.visible = true;
}
/**
*
*/
function initializeNewInstanceDialog() {
configRepository
.getBool('instanceDialogQueueEnabled', true)
@@ -860,6 +888,9 @@
.getString('instanceDialogDisplayName', '')
.then((value) => (newInstanceDialog.value.displayName = value));
}
/**
*
*/
function saveNewInstanceDialog() {
const {
accessType,
@@ -883,6 +914,10 @@
configRepository.setBool('instanceDialogAgeGate', ageGate);
configRepository.setString('instanceDialogDisplayName', displayName);
}
/**
*
* @param tabName
*/
function newInstanceTabClick(tabName) {
if (tabName === 'Normal') {
buildInstance();
@@ -890,6 +925,10 @@
buildLegacyInstance();
}
}
/**
*
* @param noChanges
*/
function updateNewInstanceDialog(noChanges) {
const D = newInstanceDialog.value;
if (D.instanceId) {
@@ -905,6 +944,10 @@
}
D.url = getLaunchURL(L);
}
/**
*
* @param location
*/
function selfInvite(location) {
const L = parseLocation(location);
if (!L.isRealInstance) {
@@ -920,6 +963,9 @@
return args;
});
}
/**
*
*/
async function handleCreateNewInstance() {
const args = await createNewInstance(newInstanceDialog.value.worldId, newInstanceDialog.value);
@@ -931,6 +977,9 @@
updateNewInstanceDialog();
}
}
/**
*
*/
function buildInstance() {
const D = newInstanceDialog.value;
D.instanceCreated = false;
@@ -965,56 +1014,37 @@
}
saveNewInstanceDialog();
}
/**
*
*/
function buildLegacyInstance() {
const D = newInstanceDialog.value;
D.instanceCreated = false;
D.shortName = '';
D.secureOrShortName = '';
const tags = [];
if (D.instanceName) {
D.instanceName = D.instanceName.replace(/[^A-Za-z0-9]/g, '');
tags.push(D.instanceName);
} else {
const randValue = (99999 * Math.random() + 1).toFixed(0);
tags.push(String(randValue).padStart(5, '0'));
}
if (!D.userId) {
D.userId = currentUser.value.id;
}
const userId = D.userId;
if (D.accessType !== 'public') {
if (D.accessType === 'friends+') {
tags.push(`~hidden(${userId})`);
} else if (D.accessType === 'friends') {
tags.push(`~friends(${userId})`);
} else if (D.accessType === 'group') {
tags.push(`~group(${D.groupId})`);
tags.push(`~groupAccessType(${D.groupAccessType})`);
} else {
tags.push(`~private(${userId})`);
}
if (D.accessType === 'invite+') {
tags.push('~canRequestInvite');
}
}
if (D.accessType === 'group' && D.ageGate) {
tags.push('~ageGate');
}
if (D.region === 'US West') {
tags.push(`~region(us)`);
} else if (D.region === 'US East') {
tags.push(`~region(use)`);
} else if (D.region === 'Europe') {
tags.push(`~region(eu)`);
} else if (D.region === 'Japan') {
tags.push(`~region(jp)`);
}
if (D.accessType !== 'invite' && D.accessType !== 'friends') {
D.strict = false;
}
if (D.strict) {
tags.push('~strict');
}
const instanceName = D.instanceName || String((99999 * Math.random() + 1).toFixed(0)).padStart(5, '0');
D.instanceId = buildLegacyInstanceTag({
instanceName,
userId: D.userId,
accessType: D.accessType,
groupId: D.groupId,
groupAccessType: D.groupAccessType,
region: D.region,
ageGate: D.ageGate,
strict: D.strict
});
if (D.groupId && D.groupId !== D.lastSelectedGroupId) {
D.roleIds = [];
const ref = cachedGroups.get(D.groupId);
@@ -1038,10 +1068,13 @@
D.groupRef = {};
D.lastSelectedGroupId = '';
}
D.instanceId = tags.join('');
updateNewInstanceDialog(false);
saveNewInstanceDialog();
}
/**
*
* @param location
*/
async function copyInstanceUrl(location) {
const L = parseLocation(location);
const args = await instanceRequest.getInstanceShortName({