fead: group member moderation ban export/import dialog (#1675)

This commit is contained in:
pa
2026-03-05 20:37:05 +09:00
parent 0034f7847b
commit b570de6d4a
12 changed files with 1353 additions and 574 deletions

View File

@@ -1,6 +1,6 @@
<template>
<Alert variant="warning" class="mb-4">
<MessageSquareWarning class="text-lg" />
<MessageSquareWarning />
<AlertTitle>{{ t('common.feature_relocated.title') }}</AlertTitle>
<AlertDescription>
<i18n-t keypath="common.feature_relocated.description" tag="span">

View File

@@ -0,0 +1,127 @@
<template>
<Dialog
:open="isGroupBansExportDialogVisible"
@update:open="
(open) => {
if (!open) setIsGroupBansExportDialogVisible();
}
">
<DialogContent class="x-dialog sm:max-w-162.5">
<DialogHeader>
<DialogTitle>{{ t('dialog.group_member_moderation.export_bans') }}</DialogTitle>
</DialogHeader>
<div style="margin-bottom: 10px" class="flex flex-col gap-2">
<label v-for="option in exportBansOptions" :key="option.label" class="inline-flex items-center gap-2">
<Checkbox
:model-value="checkedExportBansOptions.includes(option.label)"
@update:modelValue="(val) => toggleExportOption(option.label, val)" />
<span>{{ t(option.text) }}</span>
</label>
</div>
<br />
<InputGroupTextareaField
v-model="exportContent"
:rows="15"
readonly
style="margin-top: 15px"
input-class="resize-none"
@click="handleCopyExportContent" />
</DialogContent>
</Dialog>
</template>
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ref, watch } from 'vue';
import { Checkbox } from '@/components/ui/checkbox';
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { useI18n } from 'vue-i18n';
import { copyToClipboard } from '../../../shared/utils';
const { t } = useI18n();
const props = defineProps({
isGroupBansExportDialogVisible: {
type: Boolean,
default: false
},
groupBansModerationTable: {
type: Object,
default: () => {}
}
});
const emit = defineEmits(['update:isGroupBansExportDialogVisible']);
watch(
() => props.isGroupBansExportDialogVisible,
(newVal) => {
if (newVal) {
updateExportContent();
}
}
);
const exportContent = ref('');
const exportBansOptions = [
{ label: 'userId', text: 'dialog.group_member_moderation.user_id' },
{ label: 'displayName', text: 'dialog.group_member_moderation.display_name' },
{ label: 'roles', text: 'dialog.group_member_moderation.roles' },
{ label: 'managerNotes', text: 'dialog.group_member_moderation.notes' },
{ label: 'joinedAt', text: 'dialog.group_member_moderation.joined_at' },
{ label: 'bannedAt', text: 'dialog.group_member_moderation.banned_at' }
];
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
function toggleExportOption(label, checked) {
const selection = checkedExportBansOptions.value;
const index = selection.indexOf(label);
if (checked && index === -1) {
selection.push(label);
} else if (!checked && index !== -1) {
selection.splice(index, 1);
}
updateExportContent();
}
function getRowValue(item, key) {
switch (key) {
case 'displayName':
return item?.user?.displayName ?? item?.$displayName ?? '';
case 'roles': {
const ids = Array.isArray(item?.roleIds) ? item.roleIds : [];
return ids.join(';');
}
default:
return item?.[key] ?? '';
}
}
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);
const header = `${sortedCheckedOptions.join(',')}\n`;
const content = props.groupBansModerationTable.data
.map((item) => sortedCheckedOptions.map((key) => formatter(String(getRowValue(item, key)))).join(','))
.join('\n');
exportContent.value = header + content;
}
function handleCopyExportContent() {
copyToClipboard(exportContent.value);
}
function setIsGroupBansExportDialogVisible() {
emit('update:isGroupBansExportDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,221 @@
<template>
<Dialog
:open="isGroupBansImportDialogVisible"
@update:open="
(open) => {
if (!open) closeDialog();
}
">
<DialogContent class="x-dialog sm:max-w-162.5">
<DialogHeader>
<DialogTitle>{{ t('dialog.group_member_moderation.import_bans') }}</DialogTitle>
</DialogHeader>
<div class="text-xs text-muted-foreground mb-2">
<p>{{ t('dialog.group_member_moderation.import_bans_description') }}</p>
</div>
<Alert variant="warning" class="mb-2">
<TriangleAlert />
<AlertDescription>
{{ t('dialog.group_member_moderation.import_bans_warning') }}
</AlertDescription>
</Alert>
<InputGroupTextareaField
v-model="csvInput"
:rows="10"
:disabled="importing"
class="mb-2"
input-class="resize-none"
:placeholder="t('dialog.group_member_moderation.import_bans_placeholder')" />
<div class="flex items-center gap-2">
<Button size="sm" :disabled="!csvInput.trim() || importing" @click="parseAndImport">
{{ t('dialog.group_member_moderation.import_bans_start') }}
</Button>
<Button v-if="importing" size="sm" variant="secondary" @click="cancelImport">
{{ t('dialog.group_member_moderation.cancel') }}
</Button>
<span v-if="importing" class="text-sm">
<Spinner class="inline-block ml-2 mr-2" />
{{ t('dialog.group_member_moderation.progress') }}
{{ progressCurrent }}/{{ progressTotal }}
</span>
</div>
<template v-if="errors">
<br />
<Button size="sm" variant="secondary" @click="errors = ''">
{{ t('dialog.group_member_moderation.import_bans_clear_errors') }}
</Button>
<pre style="white-space: pre-wrap; font-size: 12px; margin-top: 5px" v-text="errors"></pre>
</template>
<template v-if="resultMessage">
<br />
<span class="text-sm">{{ resultMessage }}</span>
</template>
</DialogContent>
</Dialog>
</template>
<script setup>
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { InputGroupTextareaField } from '@/components/ui/input-group';
import { Spinner } from '@/components/ui/spinner';
import { TriangleAlert } from 'lucide-vue-next';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { groupRequest } from '../../../api';
import * as workerTimers from 'worker-timers';
const { t } = useI18n();
const props = defineProps({
isGroupBansImportDialogVisible: {
type: Boolean,
default: false
},
groupId: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:isGroupBansImportDialogVisible', 'imported']);
const csvInput = ref('');
const importing = ref(false);
const cancelled = ref(false);
const progressCurrent = ref(0);
const progressTotal = ref(0);
const errors = ref('');
const resultMessage = ref('');
/**
* Parse CSV input and extract user IDs.
* Supports:
* - Raw list of user IDs (one per line)
* - CSV with header row containing a "userId" column
* - Any column containing usr_ prefixed IDs
* @param input
*/
function extractUserIds(input) {
const lines = input
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) return [];
const userIdRegex = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
// Try CSV with header: check if first line contains "userId"
const firstLine = lines[0];
const headers = firstLine.split(',').map((h) => h.trim());
const userIdColIndex = headers.findIndex((h) => h.toLowerCase() === 'userid');
if (userIdColIndex !== -1 && lines.length > 1) {
// CSV mode: extract from specific column
const ids = new Set();
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',');
if (cols.length > userIdColIndex) {
const val = cols[userIdColIndex].trim().replace(/^"|"$/g, '');
if (val.startsWith('usr_')) {
ids.add(val);
}
}
}
return [...ids];
}
// Fallback: extract all usr_ IDs from entire input
const ids = new Set();
let match;
while ((match = userIdRegex.exec(input)) !== null) {
ids.add(match[0]);
}
return [...ids];
}
/**
*
*/
async function parseAndImport() {
const userIds = extractUserIds(csvInput.value);
if (userIds.length === 0) {
errors.value = 'No valid user IDs found (usr_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)';
return;
}
importing.value = true;
cancelled.value = false;
progressCurrent.value = 0;
progressTotal.value = userIds.length;
errors.value = '';
resultMessage.value = '';
let successCount = 0;
for (let i = 0; i < userIds.length; i++) {
if (cancelled.value) break;
const userId = userIds[i];
progressCurrent.value = i + 1;
try {
await groupRequest.banGroupMember({
groupId: props.groupId,
userId
});
successCount++;
} catch (err) {
errors.value += `${userId}: ${err}\n`;
}
// Rate limit delay between requests
if (i < userIds.length - 1 && !cancelled.value) {
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
}
resultMessage.value = cancelled.value
? `Cancelled. Banned ${successCount}/${progressCurrent.value} users.`
: `Done. Banned ${successCount}/${userIds.length} users.`;
importing.value = false;
progressCurrent.value = 0;
progressTotal.value = 0;
if (successCount > 0) {
emit('imported');
}
}
/**
*
*/
function cancelImport() {
cancelled.value = true;
}
/**
*
*/
function closeDialog() {
if (importing.value) {
cancelImport();
}
csvInput.value = '';
errors.value = '';
resultMessage.value = '';
emit('update:isGroupBansImportDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { getAuditLogTypeName, resolveRoleNames } from '../groupModerationUtils';
describe('getAuditLogTypeName', () => {
it('converts dotted audit log type to title case', () => {
expect(getAuditLogTypeName('group.member.ban')).toBe('Member Ban');
});
it('handles single segment after group prefix', () => {
expect(getAuditLogTypeName('group.update')).toBe('Update');
});
it('handles multiple segments', () => {
expect(getAuditLogTypeName('group.role.member.add')).toBe(
'Role Member Add'
);
});
it('returns empty string for falsy input', () => {
expect(getAuditLogTypeName('')).toBe('');
expect(getAuditLogTypeName(null)).toBe('');
expect(getAuditLogTypeName(undefined)).toBe('');
});
});
describe('resolveRoleNames', () => {
const roles = [
{ id: 'role_1', name: 'Admin' },
{ id: 'role_2', name: 'Moderator' },
{ id: 'role_3', name: 'Member' }
];
it('resolves role IDs to comma-separated names', () => {
expect(resolveRoleNames(['role_1', 'role_3'], roles)).toBe(
'Admin, Member'
);
});
it('returns empty string for empty roleIds', () => {
expect(resolveRoleNames([], roles)).toBe('');
});
it('skips unknown role IDs', () => {
expect(resolveRoleNames(['role_1', 'role_unknown'], roles)).toBe(
'Admin'
);
});
it('handles non-array roleIds gracefully', () => {
expect(resolveRoleNames(null, roles)).toBe('');
expect(resolveRoleNames(undefined, roles)).toBe('');
});
it('handles non-array roles gracefully', () => {
expect(resolveRoleNames(['role_1'], null)).toBe('');
});
it('returns single name without comma', () => {
expect(resolveRoleNames(['role_2'], roles)).toBe('Moderator');
});
});

View File

@@ -0,0 +1,32 @@
/**
* Convert an audit log type string to a human-readable name.
* e.g. 'group.member.ban' → 'Member Ban'
* @param {string} auditLogType
* @returns {string}
*/
export function getAuditLogTypeName(auditLogType) {
if (!auditLogType) return '';
return auditLogType
.replace('group.', '')
.replace(/\./g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
/**
* Resolve an array of role IDs to a comma-separated string of role names.
* @param {Array<string>} roleIds
* @param {Array<{id: string, name: string}>} roles - available roles
* @returns {string}
*/
export function resolveRoleNames(roleIds, roles) {
const ids = Array.isArray(roleIds) ? roleIds : [];
const roleList = Array.isArray(roles) ? roles : [];
const names = [];
for (const id of ids) {
const role = roleList.find((r) => r?.id === id);
if (role?.name) {
names.push(role.name);
}
}
return names.join(', ');
}

View File

@@ -0,0 +1,321 @@
import { ref } from 'vue';
import { toast } from 'vue-sonner';
/**
* Composable for batch moderation operations with progress tracking.
* @param {object} deps
* @param {import('vue').Ref} deps.selectedUsersArray
* @param {import('vue').Ref} deps.currentUser
* @param {import('vue').Ref} deps.groupMemberModeration
* @param {Function} deps.deselectedUsers
* @param {object} deps.groupRequest
* @param {Function} deps.handleGroupMemberRoleChange
* @param {Function} deps.handleGroupMemberProps
*/
export function useGroupBatchOperations(deps) {
const progressCurrent = ref(0);
const progressTotal = ref(0);
/**
* Generic batch operation runner.
* @param {object} options
* @param {Function} options.action - async (user, groupId) => void
* @param {string} options.logPrefix - e.g. 'Banning'
* @param {string} options.successMessage - e.g. 'Banned {count} group members'
* @param {string} options.errorMessage - e.g. 'Failed to ban group member'
* @param {boolean} [options.skipSelf]
* @param {Function} [options.onComplete] - called after the loop finishes
* @returns {Promise<void>}
*/
async function runBatchOperation({
action,
logPrefix,
successMessage,
errorMessage,
skipSelf = true,
onComplete
}) {
const users = [...deps.selectedUsersArray.value];
const memberCount = users.length;
const groupId = deps.groupMemberModeration.value.id;
progressTotal.value = memberCount;
let allSuccess = true;
for (let i = 0; i < memberCount; i++) {
if (!progressTotal.value) break;
const user = users[i];
progressCurrent.value = i + 1;
if (skipSelf && user.userId === deps.currentUser.value.id) continue;
console.log(`${logPrefix} ${user.userId} ${i + 1}/${memberCount}`);
try {
await action(user, groupId);
} catch (err) {
console.error(err);
toast.error(`${errorMessage}: ${err}`);
allSuccess = false;
}
}
if (allSuccess) {
toast.success(successMessage.replace('{count}', memberCount));
}
progressCurrent.value = 0;
progressTotal.value = 0;
deps.deselectedUsers(null, true);
if (onComplete) onComplete();
}
/**
*
* @param callbacks
*/
async function groupMembersBan(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.banGroupMember({
groupId,
userId: user.userId
}),
logPrefix: 'Banning',
successMessage: 'Banned {count} group members',
errorMessage: 'Failed to ban group member',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersUnban(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.unbanGroupMember({
groupId,
userId: user.userId
}),
logPrefix: 'Unbanning',
successMessage: 'Unbanned {count} group members',
errorMessage: 'Failed to unban group member',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersKick(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.kickGroupMember({
groupId,
userId: user.userId
}),
logPrefix: 'Kicking',
successMessage: 'Kicked {count} group members',
errorMessage: 'Failed to kick group member',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param noteValue
* @param callbacks
*/
async function groupMembersSaveNote(noteValue, callbacks) {
await runBatchOperation({
action: async (user, groupId) => {
if (user.managerNotes === noteValue) return;
const args = await deps.groupRequest.setGroupMemberProps(
user.userId,
groupId,
{
managerNotes: noteValue
}
);
deps.handleGroupMemberProps(args);
},
logPrefix: 'Setting note for',
successMessage: 'Saved notes for {count} group members',
errorMessage: 'Failed to set group member note for',
skipSelf: false,
onComplete: callbacks?.onComplete
});
}
/**
*
* @param roleIds
* @param callbacks
*/
async function groupMembersRemoveRoles(roleIds, callbacks) {
const rolesToRemoveSet = new Set(roleIds);
await runBatchOperation({
action: async (user, groupId) => {
const currentRoles = new Set(user.roleIds || []);
const rolesToRemoveForUser = [];
rolesToRemoveSet.forEach((roleId) => {
if (currentRoles.has(roleId)) {
rolesToRemoveForUser.push(roleId);
}
});
if (!rolesToRemoveForUser.length) return;
for (const roleId of rolesToRemoveForUser) {
const args = await deps.groupRequest.removeGroupMemberRole({
groupId,
userId: user.userId,
roleId
});
deps.handleGroupMemberRoleChange(args);
}
},
logPrefix: 'Removing roles from',
successMessage: 'Roles removed',
errorMessage: 'Failed to remove group member roles',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param roleIds
* @param callbacks
*/
async function groupMembersAddRoles(roleIds, callbacks) {
const rolesToAddSet = new Set(roleIds);
await runBatchOperation({
action: async (user, groupId) => {
const currentRoles = new Set(user.roleIds || []);
const rolesToAddForUser = [];
rolesToAddSet.forEach((roleId) => {
if (!currentRoles.has(roleId)) {
rolesToAddForUser.push(roleId);
}
});
if (!rolesToAddForUser.length) return;
for (const roleId of rolesToAddForUser) {
const args = await deps.groupRequest.addGroupMemberRole({
groupId,
userId: user.userId,
roleId
});
deps.handleGroupMemberRoleChange(args);
}
},
logPrefix: 'Adding roles to',
successMessage: 'Added group member roles',
errorMessage: 'Failed to add group member roles',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersDeleteSentInvite(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.deleteSentGroupInvite({
groupId,
userId: user.userId
}),
logPrefix: 'Deleting group invite',
successMessage: 'Deleted {count} group invites',
errorMessage: 'Failed to delete group invites',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersAcceptInviteRequest(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.acceptGroupInviteRequest({
groupId,
userId: user.userId
}),
logPrefix: 'Accepting group join request from',
successMessage: 'Accepted {count} group join requests',
errorMessage: 'Failed to accept group join requests',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersRejectInviteRequest(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.rejectGroupInviteRequest({
groupId,
userId: user.userId
}),
logPrefix: 'Rejecting group join request from',
successMessage: 'Rejected {count} group join requests',
errorMessage: 'Failed to reject group join requests',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersBlockJoinRequest(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.blockGroupInviteRequest({
groupId,
userId: user.userId
}),
logPrefix: 'Blocking group join request from',
successMessage: 'Blocked {count} group join requests',
errorMessage: 'Failed to block group join requests',
onComplete: callbacks?.onComplete
});
}
/**
*
* @param callbacks
*/
async function groupMembersDeleteBlockedRequest(callbacks) {
await runBatchOperation({
action: (user, groupId) =>
deps.groupRequest.deleteBlockedGroupRequest({
groupId,
userId: user.userId
}),
logPrefix: 'Deleting blocked group request for',
successMessage: 'Deleted {count} blocked group requests',
errorMessage: 'Failed to delete blocked group requests',
onComplete: callbacks?.onComplete
});
}
return {
progressCurrent,
progressTotal,
groupMembersBan,
groupMembersUnban,
groupMembersKick,
groupMembersSaveNote,
groupMembersRemoveRoles,
groupMembersAddRoles,
groupMembersDeleteSentInvite,
groupMembersAcceptInviteRequest,
groupMembersRejectInviteRequest,
groupMembersBlockJoinRequest,
groupMembersDeleteBlockedRequest
};
}

View File

@@ -0,0 +1,123 @@
import { reactive, ref } from 'vue';
/**
* Composable for managing selected users across all moderation tables.
* @param {object} tables - reactive table objects with `.data` arrays
* @param {object} tables.members
* @param {object} tables.bans
* @param {object} tables.invites
* @param {object} tables.joinRequests
* @param {object} tables.blocked
*/
export function useGroupModerationSelection(tables) {
const selectedUsers = reactive({});
const selectedUsersArray = ref([]);
/**
* @param {string} userId
* @param {object} user
*/
function setSelectedUsers(userId, user) {
if (!user) return;
selectedUsers[userId] = user;
selectedUsersArray.value = Object.values(selectedUsers);
}
/**
* @param {string|null} userId
* @param {boolean} isAll
*/
function deselectedUsers(userId, isAll = false) {
if (isAll) {
for (const id in selectedUsers) {
if (Object.prototype.hasOwnProperty.call(selectedUsers, id)) {
delete selectedUsers[id];
}
}
} else {
if (Object.prototype.hasOwnProperty.call(selectedUsers, userId)) {
delete selectedUsers[userId];
}
}
selectedUsersArray.value = Object.values(selectedUsers);
}
/**
* @param {object} row
*/
function onSelectionChange(row) {
if (row.$selected && !selectedUsers[row.userId]) {
setSelectedUsers(row.userId, row);
} else if (!row.$selected && selectedUsers[row.userId]) {
deselectedUsers(row.userId);
}
}
/**
* Deselect a user across all tables.
* @param {string} [userId] - if omitted, deselects all rows in all tables
*/
function deselectInTables(userId) {
const allTables = [
tables.members,
tables.bans,
tables.invites,
tables.joinRequests,
tables.blocked
];
for (const table of allTables) {
if (!table?.data) continue;
if (userId) {
const row = table.data.find((item) => item.userId === userId);
if (row) {
row.$selected = false;
}
} else {
table.data.forEach((row) => {
if (row.$selected) {
row.$selected = false;
}
});
}
}
}
/**
* @param {object} user
*/
function deleteSelectedUser(user) {
deselectedUsers(user.userId);
deselectInTables(user.userId);
}
/**
*
*/
function clearAllSelected() {
deselectedUsers(null, true);
deselectInTables();
}
/**
* Select all rows in a given table data array.
* @param {Array} tableData
*/
function selectAll(tableData) {
tableData.forEach((row) => {
row.$selected = true;
setSelectedUsers(row.userId, row);
});
}
return {
selectedUsers,
selectedUsersArray,
setSelectedUsers,
deselectedUsers,
onSelectionChange,
deselectInTables,
deleteSelectedUser,
clearAllSelected,
selectAll
};
}

View File

@@ -5,7 +5,7 @@ export { default as AlertDescription } from './AlertDescription.vue';
export { default as AlertTitle } from './AlertTitle.vue';
export const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-1 [&>svg]:text-current',
{
variants: {
variant: {

View File

@@ -1915,7 +1915,14 @@
"selected_roles": "Selected Roles",
"remove_roles": "Remove Roles",
"add_roles": "Add Roles",
"export_logs": "Export Logs"
"export_logs": "Export Logs",
"export_bans": "Export Bans",
"import_bans": "Import Bans",
"import_bans_description": "Paste CSV or user IDs below. The userId column will be auto-detected from CSV headers, or all usr_ IDs will be extracted.",
"import_bans_warning": "Do not import too many users at once. The API is rate-limited and may fail. You are responsible for any consequences.",
"import_bans_placeholder": "Paste CSV or user IDs here (one per line)\nusr_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"import_bans_start": "Import & Ban",
"import_bans_clear_errors": "Clear Errors"
},
"group_post_edit": {
"header": "Create/Edit Post",

View File

@@ -1,4 +1,5 @@
import {
generateEmojiStyle,
getEmojiFileName,
getPrintFileName,
getPrintLocalDate
@@ -311,4 +312,47 @@ describe('Gallery Utils', () => {
});
});
});
describe('generateEmojiStyle', () => {
test('returns CSS with background url and animation', () => {
const style = generateEmojiStyle(
'https://example.com/emoji.png',
10,
4,
'linear',
100
);
expect(style).toContain("url('https://example.com/emoji.png')");
expect(style).toContain('animation:');
expect(style).toContain('steps(1)');
});
test('uses 2 framesPerLine for frameCount <= 4', () => {
const style = generateEmojiStyle('u', 10, 4, 'linear', 100);
// frameSize = 1024/2 = 512
expect(style).toContain('512px');
});
test('uses 4 framesPerLine for frameCount 5-16', () => {
const style = generateEmojiStyle('u', 10, 8, 'linear', 100);
// frameSize = 1024/4 = 256
expect(style).toContain('256px');
});
test('uses 8 framesPerLine for frameCount > 16', () => {
const style = generateEmojiStyle('u', 10, 20, 'linear', 100);
// frameSize = 1024/8 = 128
expect(style).toContain('128px');
});
test('uses alternate for pingpong loopStyle', () => {
const style = generateEmojiStyle('u', 10, 4, 'pingpong', 100);
expect(style).toContain('alternate');
});
test('uses none for non-pingpong loopStyle', () => {
const style = generateEmojiStyle('u', 10, 4, 'linear', 100);
expect(style).toMatch(/\bnone\b/);
});
});
});

View File

@@ -1,4 +1,9 @@
import { removeFromArray } from '../array';
import {
removeFromArray,
arraysMatch,
moveArrayItem,
replaceReactiveObject
} from '../array';
describe('Array Utils', () => {
describe('removeFromArray', () => {
@@ -33,4 +38,91 @@ describe('Array Utils', () => {
expect(arr).toEqual([1, 2]);
});
});
describe('arraysMatch', () => {
test('returns true for identical arrays', () => {
expect(arraysMatch([1, 2, 3], [1, 2, 3])).toBe(true);
});
test('returns false for different lengths', () => {
expect(arraysMatch([1, 2], [1, 2, 3])).toBe(false);
});
test('returns false for different content', () => {
expect(arraysMatch([1, 2], [1, 3])).toBe(false);
});
test('returns true for empty arrays', () => {
expect(arraysMatch([], [])).toBe(true);
});
test('returns false for non-array first arg', () => {
expect(arraysMatch(null, [])).toBe(false);
});
test('returns false for non-array second arg', () => {
expect(arraysMatch([], null)).toBe(false);
});
test('deep-compares objects via JSON', () => {
expect(arraysMatch([{ a: 1 }], [{ a: 1 }])).toBe(true);
expect(arraysMatch([{ a: 1 }], [{ a: 2 }])).toBe(false);
});
});
describe('moveArrayItem', () => {
test('moves item forward', () => {
const arr = ['a', 'b', 'c', 'd'];
moveArrayItem(arr, 0, 2);
expect(arr).toEqual(['b', 'c', 'a', 'd']);
});
test('moves item backward', () => {
const arr = ['a', 'b', 'c', 'd'];
moveArrayItem(arr, 3, 1);
expect(arr).toEqual(['a', 'd', 'b', 'c']);
});
test('no-ops when fromIndex equals toIndex', () => {
const arr = [1, 2, 3];
moveArrayItem(arr, 1, 1);
expect(arr).toEqual([1, 2, 3]);
});
test('no-ops for negative fromIndex', () => {
const arr = [1, 2, 3];
moveArrayItem(arr, -1, 0);
expect(arr).toEqual([1, 2, 3]);
});
test('no-ops for out-of-bounds toIndex', () => {
const arr = [1, 2, 3];
moveArrayItem(arr, 0, 5);
expect(arr).toEqual([1, 2, 3]);
});
test('no-ops for non-array input', () => {
expect(() => moveArrayItem(null, 0, 1)).not.toThrow();
});
});
describe('replaceReactiveObject', () => {
test('replaces all keys from source', () => {
const target = { a: 1, b: 2 };
replaceReactiveObject(target, { c: 3 });
expect(target).toEqual({ c: 3 });
});
test('clears target when source is empty', () => {
const target = { a: 1, b: 2 };
replaceReactiveObject(target, {});
expect(target).toEqual({});
});
test('overwrites existing keys', () => {
const target = { a: 1 };
replaceReactiveObject(target, { a: 99, b: 2 });
expect(target).toEqual({ a: 99, b: 2 });
});
});
});