mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-26 18:23:47 +02:00
add test
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { getGroupName, getWorldName, parseLocation, resolveRegion, translateAccessType } from '../shared/utils';
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
useGroupStore,
|
||||
@@ -42,7 +43,6 @@
|
||||
useSearchStore,
|
||||
useWorldStore
|
||||
} from '../stores';
|
||||
import { getGroupName, getWorldName, parseLocation } from '../shared/utils';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function currentInstanceId() {
|
||||
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
|
||||
return props.traveling;
|
||||
@@ -125,6 +128,9 @@
|
||||
return props.location;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function resetState() {
|
||||
text.value = '';
|
||||
region.value = '';
|
||||
@@ -135,6 +141,9 @@
|
||||
instanceName.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function parse() {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
@@ -159,6 +168,10 @@
|
||||
strict.value = L.strict;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function applyInstanceRef(L) {
|
||||
const instanceRef = cachedInstances.get(L.tag);
|
||||
if (typeof instanceRef === 'undefined') {
|
||||
@@ -173,6 +186,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
* @param instanceId
|
||||
*/
|
||||
function updateGroupName(L, instanceId) {
|
||||
if (props.grouphint) {
|
||||
groupName.value = props.grouphint;
|
||||
@@ -189,18 +207,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function updateRegion(L) {
|
||||
region.value = '';
|
||||
if (!L.isOffline && !L.isPrivate && !L.isTraveling) {
|
||||
region.value = L.region;
|
||||
if (!L.region && L.instanceId) {
|
||||
region.value = 'us';
|
||||
}
|
||||
}
|
||||
region.value = resolveRegion(L);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param accessTypeName
|
||||
*/
|
||||
function getAccessTypeLabel(accessTypeName) {
|
||||
return translateAccessType(accessTypeName, t, accessTypeLocaleKeyMap);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function setText(L) {
|
||||
const accessTypeLabel = translateAccessType(L.accessTypeName);
|
||||
const accessTypeLabel = getAccessTypeLabel(L.accessTypeName);
|
||||
|
||||
if (L.isOffline) {
|
||||
text.value = t('location.offline');
|
||||
@@ -225,7 +253,7 @@
|
||||
getWorldName(L.worldId).then((name) => {
|
||||
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
||||
if (L.instanceId) {
|
||||
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`;
|
||||
text.value = `${name} · ${getAccessTypeLabel(L.accessTypeName)}`;
|
||||
} else {
|
||||
text.value = name;
|
||||
}
|
||||
@@ -239,18 +267,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function translateAccessType(accessTypeNameRaw) {
|
||||
const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
|
||||
if (!key) {
|
||||
return accessTypeNameRaw;
|
||||
}
|
||||
if (accessTypeNameRaw === 'groupPublic' || accessTypeNameRaw === 'groupPlus') {
|
||||
const groupKey = accessTypeLocaleKeyMap['group'];
|
||||
return t(groupKey) + ' ' + t(key);
|
||||
}
|
||||
return t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowWorldDialog() {
|
||||
if (props.link) {
|
||||
let instanceId = currentInstanceId();
|
||||
@@ -266,6 +285,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowGroupDialog() {
|
||||
let location = currentInstanceId();
|
||||
if (!location) {
|
||||
|
||||
@@ -356,6 +356,7 @@
|
||||
useUiStore,
|
||||
useVRCXUpdaterStore
|
||||
} from '../stores';
|
||||
import { getFirstNavRoute, isEntryNotified, normalizeHiddenKeys, sanitizeLayout } from './navMenuUtils';
|
||||
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
||||
import { openExternalLink } from '../shared/utils';
|
||||
|
||||
@@ -418,23 +419,6 @@
|
||||
];
|
||||
|
||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
||||
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
||||
|
||||
const normalizeHiddenKeys = (hiddenKeys = []) => {
|
||||
if (!Array.isArray(hiddenKeys)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
hiddenKeys.forEach((key) => {
|
||||
if (!key || seen.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
});
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||
@@ -498,7 +482,7 @@
|
||||
const currentRouteName = currentRoute?.name;
|
||||
const navKey = currentRoute?.meta?.navKey || currentRouteName;
|
||||
if (!navKey) {
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
return getFirstNavRouteLocal(navLayout.value) || 'feed';
|
||||
}
|
||||
|
||||
for (const entry of navLayout.value) {
|
||||
@@ -510,7 +494,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
return getFirstNavRouteLocal(navLayout.value) || 'feed';
|
||||
});
|
||||
|
||||
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
||||
@@ -553,90 +537,8 @@
|
||||
return `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||
};
|
||||
|
||||
const sanitizeLayout = (layout, hiddenKeys = []) => {
|
||||
const usedKeys = new Set();
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||
const normalized = [];
|
||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||
|
||||
const appendItemEntry = (key, target = normalized) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
target.push({ type: 'item', key });
|
||||
usedKeys.add(key);
|
||||
};
|
||||
|
||||
const appendChartsFolder = (target = normalized) => {
|
||||
if (chartsKeys.some((key) => usedKeys.has(key))) {
|
||||
return;
|
||||
}
|
||||
if (!chartsKeys.every((key) => navDefinitionMap.has(key))) {
|
||||
return;
|
||||
}
|
||||
chartsKeys.forEach((key) => usedKeys.add(key));
|
||||
target.push({
|
||||
type: 'folder',
|
||||
id: 'default-folder-charts',
|
||||
nameKey: 'nav_tooltip.charts',
|
||||
name: t('nav_tooltip.charts'),
|
||||
icon: 'ri-pie-chart-line',
|
||||
items: [...chartsKeys]
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(layout)) {
|
||||
layout.forEach((entry) => {
|
||||
if (entry?.type === 'item') {
|
||||
if (entry.key === 'charts') {
|
||||
appendChartsFolder();
|
||||
return;
|
||||
}
|
||||
appendItemEntry(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.type === 'folder') {
|
||||
const folderItems = [];
|
||||
(entry.items || []).forEach((key) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
folderItems.push(key);
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 1) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
||||
normalized.push({
|
||||
type: 'folder',
|
||||
id: entry.id || generateFolderId(),
|
||||
name: folderName,
|
||||
nameKey: folderNameKey,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||
if (chartsKeys.includes(item.key)) {
|
||||
return;
|
||||
}
|
||||
appendItemEntry(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!chartsKeys.some((key) => usedKeys.has(key)) && !chartsKeys.some((key) => hiddenSet.has(key))) {
|
||||
appendChartsFolder();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
const sanitizeLayoutLocal = (layout, hiddenKeys = []) => {
|
||||
return sanitizeLayout(layout, hiddenKeys, navDefinitionMap, navDefinitions, t, generateFolderId);
|
||||
};
|
||||
|
||||
const themeDisplayName = (themeKey) => {
|
||||
@@ -693,10 +595,10 @@
|
||||
|
||||
const customNavDialogVisible = ref(false);
|
||||
const navHiddenKeys = ref([]);
|
||||
const defaultNavLayout = computed(() => sanitizeLayout(createDefaultNavLayout(), []));
|
||||
const defaultNavLayout = computed(() => sanitizeLayoutLocal(createDefaultNavLayout(), []));
|
||||
|
||||
const saveNavLayout = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||
try {
|
||||
await configRepository.setString(
|
||||
'VRCX_customNavMenuLayoutList',
|
||||
@@ -715,8 +617,8 @@
|
||||
};
|
||||
|
||||
const handleCustomNavSave = async (layout, hiddenKeys = []) => {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys);
|
||||
const sanitized = sanitizeLayout(layout, normalizedHiddenKeys);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, navDefinitionMap);
|
||||
const sanitized = sanitizeLayoutLocal(layout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
await saveNavLayout(sanitized, normalizedHiddenKeys);
|
||||
@@ -740,9 +642,9 @@
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nav', error);
|
||||
} finally {
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData);
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeysData, navDefinitionMap);
|
||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||
const sanitized = sanitizeLayout(fallbackLayout, normalizedHiddenKeys);
|
||||
const sanitized = sanitizeLayoutLocal(fallbackLayout, normalizedHiddenKeys);
|
||||
navLayout.value = sanitized;
|
||||
navHiddenKeys.value = normalizedHiddenKeys;
|
||||
if (
|
||||
@@ -764,26 +666,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const isEntryNotified = (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const targets = [];
|
||||
if (entry.index) {
|
||||
targets.push(entry.index);
|
||||
}
|
||||
if (entry.routeName) {
|
||||
targets.push(entry.routeName);
|
||||
}
|
||||
if (entry.path) {
|
||||
const lastSegment = entry.path.split('/').pop();
|
||||
if (lastSegment) {
|
||||
targets.push(lastSegment);
|
||||
}
|
||||
}
|
||||
return targets.some((key) => notifiedMenus.value.includes(key));
|
||||
};
|
||||
|
||||
const isNavItemNotified = (item) => {
|
||||
if (!item) {
|
||||
return false;
|
||||
@@ -792,7 +674,7 @@
|
||||
return true;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
return item.children.some((entry) => isEntryNotified(entry));
|
||||
return item.children.some((entry) => isEntryNotified(entry, notifiedMenus.value));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -828,30 +710,14 @@
|
||||
*
|
||||
* @param layout
|
||||
*/
|
||||
function getFirstNavRoute(layout) {
|
||||
for (const entry of layout) {
|
||||
if (entry.type === 'item') {
|
||||
const definition = navDefinitionMap.get(entry.key);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.length) {
|
||||
const definition = entry.items.map((key) => navDefinitionMap.get(key)).find((def) => def?.routeName);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const getFirstNavRouteLocal = (layout) => getFirstNavRoute(layout, navDefinitionMap);
|
||||
|
||||
let hasNavigatedToInitialRoute = false;
|
||||
const navigateToFirstNavEntry = () => {
|
||||
if (hasNavigatedToInitialRoute) {
|
||||
return;
|
||||
}
|
||||
const firstRoute = getFirstNavRoute(navLayout.value);
|
||||
const firstRoute = getFirstNavRouteLocal(navLayout.value);
|
||||
if (!firstRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
311
src/components/__tests__/navMenuUtils.test.js
Normal file
311
src/components/__tests__/navMenuUtils.test.js
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
getFirstNavRoute,
|
||||
isEntryNotified,
|
||||
normalizeHiddenKeys,
|
||||
sanitizeLayout
|
||||
} from '../navMenuUtils';
|
||||
|
||||
// Minimal nav definitions for testing
|
||||
const testDefinitions = [
|
||||
{ key: 'feed', routeName: 'feed' },
|
||||
{ key: 'search', routeName: 'search' },
|
||||
{ key: 'tools', routeName: 'tools' },
|
||||
{ key: 'charts-instance', routeName: 'charts-instance' },
|
||||
{ key: 'charts-mutual', routeName: 'charts-mutual' },
|
||||
{ key: 'notification', routeName: 'notification' },
|
||||
{ key: 'direct-access', action: 'direct-access' }
|
||||
];
|
||||
const testDefinitionMap = new Map(testDefinitions.map((d) => [d.key, d]));
|
||||
const mockT = (key) => `translated:${key}`;
|
||||
const mockGenerateFolderId = () => 'generated-folder-id';
|
||||
|
||||
// ─── normalizeHiddenKeys ─────────────────────────────────────────────
|
||||
|
||||
describe('normalizeHiddenKeys', () => {
|
||||
test('returns empty array for non-array input', () => {
|
||||
expect(normalizeHiddenKeys(null, testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys(undefined, testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys('string', testDefinitionMap)).toEqual([]);
|
||||
expect(normalizeHiddenKeys(42, testDefinitionMap)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty array for empty array', () => {
|
||||
expect(normalizeHiddenKeys([], testDefinitionMap)).toEqual([]);
|
||||
});
|
||||
|
||||
test('filters out invalid keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(
|
||||
['feed', 'nonexistent', 'search'],
|
||||
testDefinitionMap
|
||||
)
|
||||
).toEqual(['feed', 'search']);
|
||||
});
|
||||
|
||||
test('deduplicates keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(['feed', 'feed', 'search'], testDefinitionMap)
|
||||
).toEqual(['feed', 'search']);
|
||||
});
|
||||
|
||||
test('filters out falsy values', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(
|
||||
[null, '', undefined, 'feed'],
|
||||
testDefinitionMap
|
||||
)
|
||||
).toEqual(['feed']);
|
||||
});
|
||||
|
||||
test('preserves order of valid keys', () => {
|
||||
expect(
|
||||
normalizeHiddenKeys(['tools', 'feed', 'search'], testDefinitionMap)
|
||||
).toEqual(['tools', 'feed', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getFirstNavRoute ────────────────────────────────────────────────
|
||||
|
||||
describe('getFirstNavRoute', () => {
|
||||
test('returns null for empty layout', () => {
|
||||
expect(getFirstNavRoute([], testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns first item routeName', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('feed');
|
||||
});
|
||||
|
||||
test('skips items without routeName', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'direct-access' },
|
||||
{ type: 'item', key: 'search' }
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('search');
|
||||
});
|
||||
|
||||
test('returns route from folder items', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
items: ['feed', 'search']
|
||||
}
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('feed');
|
||||
});
|
||||
|
||||
test('returns null when no routable items exist', () => {
|
||||
const layout = [{ type: 'item', key: 'direct-access' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unknown keys', () => {
|
||||
const layout = [{ type: 'item', key: 'unknown' }];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBeNull();
|
||||
});
|
||||
|
||||
test('checks folder items for routable entry', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
items: ['direct-access', 'tools']
|
||||
}
|
||||
];
|
||||
expect(getFirstNavRoute(layout, testDefinitionMap)).toBe('tools');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEntryNotified ─────────────────────────────────────────────────
|
||||
|
||||
describe('isEntryNotified', () => {
|
||||
test('returns false for null/undefined entry', () => {
|
||||
expect(isEntryNotified(null, ['feed'])).toBe(false);
|
||||
expect(isEntryNotified(undefined, ['feed'])).toBe(false);
|
||||
});
|
||||
|
||||
test('matches by index', () => {
|
||||
const entry = { index: 'feed' };
|
||||
expect(isEntryNotified(entry, ['feed', 'search'])).toBe(true);
|
||||
});
|
||||
|
||||
test('matches by routeName', () => {
|
||||
const entry = { routeName: 'search' };
|
||||
expect(isEntryNotified(entry, ['search'])).toBe(true);
|
||||
});
|
||||
|
||||
test('matches by path last segment', () => {
|
||||
const entry = { path: '/app/settings' };
|
||||
expect(isEntryNotified(entry, ['settings'])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false when no match', () => {
|
||||
const entry = { index: 'feed', routeName: 'feed' };
|
||||
expect(isEntryNotified(entry, ['search', 'tools'])).toBe(false);
|
||||
});
|
||||
|
||||
test('matches any of multiple targets', () => {
|
||||
const entry = {
|
||||
index: 'feed',
|
||||
routeName: 'home',
|
||||
path: '/app/dashboard'
|
||||
};
|
||||
expect(isEntryNotified(entry, ['dashboard'])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for empty notifiedMenus', () => {
|
||||
const entry = { index: 'feed' };
|
||||
expect(isEntryNotified(entry, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanitizeLayout ──────────────────────────────────────────────────
|
||||
|
||||
describe('sanitizeLayout', () => {
|
||||
const runSanitize = (layout, hiddenKeys = []) =>
|
||||
sanitizeLayout(
|
||||
layout,
|
||||
hiddenKeys,
|
||||
testDefinitionMap,
|
||||
testDefinitions,
|
||||
mockT,
|
||||
mockGenerateFolderId
|
||||
);
|
||||
|
||||
test('returns default items for null/undefined layout', () => {
|
||||
const result = runSanitize(null);
|
||||
// Should include all non-chart items + charts folder
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.some((e) => e.type === 'item' && e.key === 'feed')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves valid item entries', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
expect(result[0]).toEqual({ type: 'item', key: 'feed' });
|
||||
});
|
||||
|
||||
test('skips invalid item keys', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'feed' },
|
||||
{ type: 'item', key: 'nonexistent' }
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
expect(result.find((e) => e.key === 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('deduplicates item keys', () => {
|
||||
const layout = [
|
||||
{ type: 'item', key: 'feed' },
|
||||
{ type: 'item', key: 'feed' }
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const feedEntries = result.filter(
|
||||
(e) => e.type === 'item' && e.key === 'feed'
|
||||
);
|
||||
expect(feedEntries.length).toBe(1);
|
||||
});
|
||||
|
||||
test('creates folder entries from valid items', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'my-folder',
|
||||
name: 'My Folder',
|
||||
icon: 'ri-star-line',
|
||||
items: ['feed', 'search']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'my-folder'
|
||||
);
|
||||
expect(folder).toBeDefined();
|
||||
expect(folder.items).toEqual(['feed', 'search']);
|
||||
expect(folder.name).toBe('My Folder');
|
||||
});
|
||||
|
||||
test('generates folder ID when missing', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
name: 'No ID Folder',
|
||||
items: ['feed']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find((e) => e.type === 'folder');
|
||||
expect(folder.id).toBe('generated-folder-id');
|
||||
});
|
||||
|
||||
test('translates folder name from nameKey', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'f1',
|
||||
nameKey: 'nav_tooltip.favorites',
|
||||
items: ['feed']
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
const folder = result.find((e) => e.type === 'folder');
|
||||
expect(folder.name).toBe('translated:nav_tooltip.favorites');
|
||||
});
|
||||
|
||||
test('appends missing definitions not in layout or hidden', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
// All non-chart, non-hidden items should be present
|
||||
expect(result.some((e) => e.key === 'search')).toBe(true);
|
||||
expect(result.some((e) => e.key === 'tools')).toBe(true);
|
||||
});
|
||||
|
||||
test('does not append hidden keys', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout, ['search', 'tools']);
|
||||
expect(
|
||||
result.find((e) => e.type === 'item' && e.key === 'search')
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.find((e) => e.type === 'item' && e.key === 'tools')
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('converts legacy "charts" item to charts folder', () => {
|
||||
const layout = [{ type: 'item', key: 'charts' }];
|
||||
const result = runSanitize(layout);
|
||||
const chartsFolder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'default-folder-charts'
|
||||
);
|
||||
expect(chartsFolder).toBeDefined();
|
||||
expect(chartsFolder.items).toEqual([
|
||||
'charts-instance',
|
||||
'charts-mutual'
|
||||
]);
|
||||
});
|
||||
|
||||
test('auto-appends charts folder when charts keys are neither used nor hidden', () => {
|
||||
const layout = [{ type: 'item', key: 'feed' }];
|
||||
const result = runSanitize(layout);
|
||||
const chartsFolder = result.find(
|
||||
(e) => e.type === 'folder' && e.id === 'default-folder-charts'
|
||||
);
|
||||
expect(chartsFolder).toBeDefined();
|
||||
});
|
||||
|
||||
test('skips empty folders', () => {
|
||||
const layout = [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'empty-folder',
|
||||
name: 'Empty',
|
||||
items: []
|
||||
}
|
||||
];
|
||||
const result = runSanitize(layout);
|
||||
expect(result.find((e) => e.id === 'empty-folder')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
183
src/components/navMenuUtils.js
Normal file
183
src/components/navMenuUtils.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const DEFAULT_FOLDER_ICON = 'ri-folder-line';
|
||||
|
||||
/**
|
||||
* Deduplicate and validate hidden navigation keys against the definition map.
|
||||
* @param {string[]} hiddenKeys - Keys to normalize
|
||||
* @param {Map} definitionMap - Map of valid nav definition keys
|
||||
* @returns {string[]} Normalized, deduplicated array of valid keys
|
||||
*/
|
||||
export function normalizeHiddenKeys(hiddenKeys, definitionMap) {
|
||||
if (!Array.isArray(hiddenKeys)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
hiddenKeys.forEach((key) => {
|
||||
if (!key || seen.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a saved navigation layout: dedup items, create folders, append missing definitions.
|
||||
* @param {Array} layout - Raw layout from storage
|
||||
* @param {string[]} hiddenKeys - Keys that should be hidden
|
||||
* @param {Map} definitionMap - Map of all valid nav definition keys
|
||||
* @param {Array} allDefinitions - Array of all nav definitions (for appending missing)
|
||||
* @param {Function} t - i18n translation function
|
||||
* @param {Function} generateFolderId - Function to generate unique folder IDs
|
||||
* @returns {Array} Sanitized layout
|
||||
*/
|
||||
export function sanitizeLayout(
|
||||
layout,
|
||||
hiddenKeys,
|
||||
definitionMap,
|
||||
allDefinitions,
|
||||
t,
|
||||
generateFolderId
|
||||
) {
|
||||
const usedKeys = new Set();
|
||||
const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, definitionMap);
|
||||
const hiddenSet = new Set(normalizedHiddenKeys);
|
||||
const normalized = [];
|
||||
const chartsKeys = ['charts-instance', 'charts-mutual'];
|
||||
|
||||
const appendItemEntry = (key, target = normalized) => {
|
||||
if (!key || usedKeys.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
target.push({ type: 'item', key });
|
||||
usedKeys.add(key);
|
||||
};
|
||||
|
||||
const appendChartsFolder = (target = normalized) => {
|
||||
if (chartsKeys.some((key) => usedKeys.has(key))) {
|
||||
return;
|
||||
}
|
||||
if (!chartsKeys.every((key) => definitionMap.has(key))) {
|
||||
return;
|
||||
}
|
||||
chartsKeys.forEach((key) => usedKeys.add(key));
|
||||
target.push({
|
||||
type: 'folder',
|
||||
id: 'default-folder-charts',
|
||||
nameKey: 'nav_tooltip.charts',
|
||||
name: t('nav_tooltip.charts'),
|
||||
icon: 'ri-pie-chart-line',
|
||||
items: [...chartsKeys]
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(layout)) {
|
||||
layout.forEach((entry) => {
|
||||
if (entry?.type === 'item') {
|
||||
if (entry.key === 'charts') {
|
||||
appendChartsFolder();
|
||||
return;
|
||||
}
|
||||
appendItemEntry(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.type === 'folder') {
|
||||
const folderItems = [];
|
||||
(entry.items || []).forEach((key) => {
|
||||
if (!key || usedKeys.has(key) || !definitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
folderItems.push(key);
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 1) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey
|
||||
? t(folderNameKey)
|
||||
: entry.name || '';
|
||||
normalized.push({
|
||||
type: 'folder',
|
||||
id: entry.id || generateFolderId(),
|
||||
name: folderName,
|
||||
nameKey: folderNameKey,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
allDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key) && !hiddenSet.has(item.key)) {
|
||||
if (chartsKeys.includes(item.key)) {
|
||||
return;
|
||||
}
|
||||
appendItemEntry(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!chartsKeys.some((key) => usedKeys.has(key)) &&
|
||||
!chartsKeys.some((key) => hiddenSet.has(key))
|
||||
) {
|
||||
appendChartsFolder();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first routable navigation key in a layout.
|
||||
* @param {Array} layout - Navigation layout
|
||||
* @param {Map} definitionMap - Map of nav definitions
|
||||
* @returns {string|null} The route name of the first routable entry, or null
|
||||
*/
|
||||
export function getFirstNavRoute(layout, definitionMap) {
|
||||
for (const entry of layout) {
|
||||
if (entry.type === 'item') {
|
||||
const definition = definitionMap.get(entry.key);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.length) {
|
||||
const definition = entry.items
|
||||
.map((key) => definitionMap.get(key))
|
||||
.find((def) => def?.routeName);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a navigation entry has a notification indicator.
|
||||
* @param {object} entry - Navigation entry object
|
||||
* @param {string[]} notifiedMenus - List of menu keys with notifications
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEntryNotified(entry, notifiedMenus) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const targets = [];
|
||||
if (entry.index) {
|
||||
targets.push(entry.index);
|
||||
}
|
||||
if (entry.routeName) {
|
||||
targets.push(entry.routeName);
|
||||
}
|
||||
if (entry.path) {
|
||||
const lastSegment = entry.path.split('/').pop();
|
||||
if (lastSegment) {
|
||||
targets.push(lastSegment);
|
||||
}
|
||||
}
|
||||
return targets.some((key) => notifiedMenus.includes(key));
|
||||
}
|
||||
Reference in New Issue
Block a user