fix: channel ordering
This commit is contained in:
@@ -114,6 +114,10 @@ export function GuildChannelController(app: HonoApp) {
|
||||
channelId: createChannelID(item.id),
|
||||
position: item.position,
|
||||
parentId: item.parent_id == null ? item.parent_id : createChannelID(item.parent_id),
|
||||
precedingSiblingId:
|
||||
item.preceding_sibling_id == null
|
||||
? item.preceding_sibling_id
|
||||
: createChannelID(item.preceding_sibling_id),
|
||||
lockPermissions: item.lock_permissions ?? false,
|
||||
})),
|
||||
requestCache,
|
||||
|
||||
@@ -112,6 +112,7 @@ export class GuildChannelService {
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
precedingSiblingId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
}>;
|
||||
requestCache: RequestCache;
|
||||
|
||||
@@ -803,6 +803,7 @@ export class GuildService {
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
precedingSiblingId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
}>;
|
||||
requestCache: RequestCache;
|
||||
|
||||
@@ -401,6 +401,7 @@ export class ChannelOperationsService {
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
precedingSiblingId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
}>;
|
||||
requestCache: RequestCache;
|
||||
@@ -424,6 +425,11 @@ export class ChannelOperationsService {
|
||||
if (update.parentId && !channelMap.has(update.parentId)) {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
|
||||
}
|
||||
if (update.precedingSiblingId && !channelMap.has(update.precedingSiblingId)) {
|
||||
throw InputValidationError.fromCode('preceding_sibling_id', ValidationErrorCodes.INVALID_CHANNEL_ID, {
|
||||
channelId: update.precedingSiblingId.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const viewable = new Set(await this.gatewayService.getViewableChannels({guildId, userId}));
|
||||
@@ -435,6 +441,9 @@ export class ChannelOperationsService {
|
||||
if (update.parentId && !viewable.has(update.parentId)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (update.precedingSiblingId && !viewable.has(update.precedingSiblingId)) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of updates) {
|
||||
@@ -453,7 +462,13 @@ export class ChannelOperationsService {
|
||||
private async applySinglePositionUpdate(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
update: {channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; lockPermissions: boolean};
|
||||
update: {
|
||||
channelId: ChannelID;
|
||||
position?: number;
|
||||
parentId: ChannelID | null | undefined;
|
||||
precedingSiblingId: ChannelID | null | undefined;
|
||||
lockPermissions: boolean;
|
||||
};
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
const {guildId, update, requestCache} = params;
|
||||
@@ -478,26 +493,29 @@ export class ChannelOperationsService {
|
||||
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS);
|
||||
}
|
||||
|
||||
const orderedChannels = sortChannelsForOrdering(allChannels);
|
||||
const siblings = orderedChannels.filter((ch) => (ch.parentId ?? null) === desiredParent);
|
||||
const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: target.id});
|
||||
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
|
||||
let precedingSibling = update.precedingSiblingId ?? null;
|
||||
if (update.precedingSiblingId === undefined) {
|
||||
const orderedChannels = sortChannelsForOrdering(allChannels);
|
||||
const siblings = orderedChannels.filter((ch) => (ch.parentId ?? null) === desiredParent);
|
||||
const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: target.id});
|
||||
const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id));
|
||||
|
||||
let insertIndex = siblingsWithoutBlock.length;
|
||||
if (update.position !== undefined) {
|
||||
const adjustedPosition = Math.max(update.position, 0);
|
||||
insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length);
|
||||
} else {
|
||||
const isVoice = target.type === ChannelTypes.GUILD_VOICE;
|
||||
if (isVoice) {
|
||||
insertIndex = siblingsWithoutBlock.length;
|
||||
let insertIndex = siblingsWithoutBlock.length;
|
||||
if (update.position !== undefined) {
|
||||
const adjustedPosition = Math.max(update.position, 0);
|
||||
insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length);
|
||||
} else {
|
||||
const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE);
|
||||
insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice;
|
||||
const isVoice = target.type === ChannelTypes.GUILD_VOICE;
|
||||
if (isVoice) {
|
||||
insertIndex = siblingsWithoutBlock.length;
|
||||
} else {
|
||||
const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE);
|
||||
insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id;
|
||||
precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id;
|
||||
}
|
||||
|
||||
await this.executeChannelReorder({
|
||||
guildId,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/Ap
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Channel Positions', () => {
|
||||
@@ -257,4 +258,163 @@ describe('Guild Channel Positions', () => {
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(destinationSiblings.map((channel) => channel.id)).toEqual([textOne.id, textTwo.id, voiceChannel.id]);
|
||||
});
|
||||
|
||||
test('should move default general text under the default voice category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
|
||||
const general = channels.find((channel) => channel.type === ChannelTypes.GUILD_TEXT && channel.name === 'general');
|
||||
const voiceCategory = channels.find(
|
||||
(channel) => channel.type === ChannelTypes.GUILD_CATEGORY && channel.name === 'Voice Channels',
|
||||
);
|
||||
const generalVoice = channels.find(
|
||||
(channel) => channel.type === ChannelTypes.GUILD_VOICE && channel.name === 'General',
|
||||
);
|
||||
|
||||
expect(general).toBeDefined();
|
||||
expect(voiceCategory).toBeDefined();
|
||||
expect(generalVoice).toBeDefined();
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{
|
||||
id: general!.id,
|
||||
parent_id: voiceCategory!.id,
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const updatedChannels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const voiceCategorySiblings = updatedChannels
|
||||
.filter((channel) => channel.parent_id === voiceCategory!.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(voiceCategorySiblings.map((channel) => channel.id)).toEqual([general!.id, generalVoice!.id]);
|
||||
});
|
||||
|
||||
test('should prioritise preceding_sibling_id over position when both are provided', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT);
|
||||
const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT);
|
||||
const textThree = await createChannel(harness, account.token, guild.id, 'three', ChannelTypes.GUILD_TEXT);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textOne.id, parent_id: category.id},
|
||||
{id: textTwo.id, parent_id: category.id},
|
||||
{id: textThree.id, parent_id: category.id},
|
||||
]);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{
|
||||
id: textThree.id,
|
||||
parent_id: category.id,
|
||||
position: 0,
|
||||
preceding_sibling_id: textOne.id,
|
||||
},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const siblings = channels
|
||||
.filter((channel) => channel.parent_id === category.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(siblings.map((channel) => channel.id)).toEqual([textOne.id, textThree.id, textTwo.id]);
|
||||
});
|
||||
|
||||
test('should reject preceding sibling from a different parent', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const categoryOne = await createChannel(harness, account.token, guild.id, 'Cat 1', ChannelTypes.GUILD_CATEGORY);
|
||||
const categoryTwo = await createChannel(harness, account.token, guild.id, 'Cat 2', ChannelTypes.GUILD_CATEGORY);
|
||||
const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT);
|
||||
const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textOne.id, parent_id: categoryOne.id},
|
||||
{id: textTwo.id, parent_id: categoryTwo.id},
|
||||
]);
|
||||
|
||||
const response = await createBuilder<{
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string; message: string}>;
|
||||
}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{
|
||||
id: textOne.id,
|
||||
parent_id: categoryOne.id,
|
||||
preceding_sibling_id: textTwo.id,
|
||||
},
|
||||
])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(response.code).toBe('INVALID_FORM_BODY');
|
||||
expect(response.errors[0]?.path).toBe('preceding_sibling_id');
|
||||
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.PRECEDING_CHANNEL_MUST_SHARE_PARENT);
|
||||
});
|
||||
|
||||
test('should reject positioning a category relative to its own child', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Cat', ChannelTypes.GUILD_CATEGORY);
|
||||
const child = await createChannel(harness, account.token, guild.id, 'child', ChannelTypes.GUILD_TEXT);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: child.id, parent_id: category.id}]);
|
||||
|
||||
const response = await createBuilder<{
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string; message: string}>;
|
||||
}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{
|
||||
id: category.id,
|
||||
parent_id: null,
|
||||
preceding_sibling_id: child.id,
|
||||
},
|
||||
])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(response.code).toBe('INVALID_FORM_BODY');
|
||||
expect(response.errors[0]?.path).toBe('preceding_sibling_id');
|
||||
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF);
|
||||
});
|
||||
|
||||
test('should reject text channels being positioned below voice channels via preceding_sibling_id', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Mixed', ChannelTypes.GUILD_CATEGORY);
|
||||
const text = await createChannel(harness, account.token, guild.id, 'text', ChannelTypes.GUILD_TEXT);
|
||||
const voice = await createChannel(harness, account.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: text.id, parent_id: category.id},
|
||||
{id: voice.id, parent_id: category.id},
|
||||
]);
|
||||
|
||||
const response = await createBuilder<{
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string; message: string}>;
|
||||
}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{
|
||||
id: text.id,
|
||||
parent_id: category.id,
|
||||
preceding_sibling_id: voice.id,
|
||||
},
|
||||
])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(response.code).toBe('INVALID_FORM_BODY');
|
||||
expect(response.errors[0]?.path).toBe('preceding_sibling_id');
|
||||
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,13 @@ export async function updateChannelPositions(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
positions: Array<{id: string; position?: number; lock_permissions?: boolean | null; parent_id?: string | null}>,
|
||||
positions: Array<{
|
||||
id: string;
|
||||
position?: number;
|
||||
lock_permissions?: boolean | null;
|
||||
parent_id?: string | null;
|
||||
preceding_sibling_id?: string | null;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).patch(`/guilds/${guildId}/channels`).body(positions).expect(204).execute();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user