feature: add alphabetical sort for user and team collections (#5383)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Mir Arif Hasan 2025-09-23 15:16:23 +06:00 committed by GitHub
parent 637c380c07
commit 81fe98f25d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3478 additions and 341 deletions

View file

@ -35,6 +35,7 @@ import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.
import { InfraTokenModule } from './infra-token/infra-token.module';
import { PrismaModule } from './prisma/prisma.module';
import { PubSubModule } from './pubsub/pubsub.module';
import { SortModule } from './orchestration/sort/sort.module';
@Module({
imports: [
@ -122,6 +123,7 @@ import { PubSubModule } from './pubsub/pubsub.module';
HealthModule,
AccessTokenModule,
InfraTokenModule,
SortModule,
],
providers: [
GQLComplexityPlugin,

View file

@ -30,6 +30,8 @@ import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
import { InfraConfigResolver } from './infra-config/infra-config.resolver';
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver';
import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver';
/**
* All the resolvers present in the application.
@ -62,6 +64,8 @@ const RESOLVERS = [
UserSettingsUserResolver,
InfraConfigResolver,
InfraTokenResolver,
SortUserCollectionResolver,
SortTeamCollectionResolver,
];
/**

View file

@ -0,0 +1,104 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { TeamCollection } from 'src/team-collection/team-collection.model';
import { SortService } from './sort.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamAccessRole } from 'src/team/team.model';
import { SortOptions } from 'src/types/SortOptions';
import * as E from 'fp-ts/Either';
import { SkipThrottle } from '@nestjs/throttler';
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
import { PubSubService } from 'src/pubsub/pubsub.service';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamCollection)
export class SortTeamCollectionResolver {
constructor(
private readonly sortService: SortService,
private readonly pubSub: PubSubService,
) {}
// Mutations
@Mutation(() => Boolean, {
description: 'Sort team collections',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamAccessRole.OWNER, TeamAccessRole.EDITOR)
async sortTeamCollections(
@Args({
name: 'teamID',
description: 'ID of the team',
type: () => ID,
})
teamID: string,
@Args({
name: 'parentCollectionID',
description: 'ID of the parent collection',
type: () => ID,
nullable: true,
})
parentCollectionID: string | null,
@Args({
name: 'sortOption',
description: 'Sorting option',
type: () => SortOptions,
})
sortOption: SortOptions,
): Promise<boolean> {
const result = await this.sortService.sortTeamCollections(
teamID,
parentCollectionID,
sortOption,
);
if (E.isLeft(result)) return false;
return true;
}
// Subscriptions
@Subscription(() => Boolean, {
description: 'Listen for Team Root Collection Sort Events',
resolve: (value) => value,
})
@RequiresTeamRole(
TeamAccessRole.OWNER,
TeamAccessRole.EDITOR,
TeamAccessRole.VIEWER,
)
@SkipThrottle()
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
teamRootCollectionsSorted(
@Args({
name: 'teamID',
description: 'ID of the team to listen to',
type: () => ID,
})
teamID: string,
) {
return this.pubSub.asyncIterator(`team_coll_root/${teamID}/sorted`);
}
@Subscription(() => ID, {
description: 'Listen for Team Child Collection Sort Events',
resolve: (value) => value,
})
@RequiresTeamRole(
TeamAccessRole.OWNER,
TeamAccessRole.EDITOR,
TeamAccessRole.VIEWER,
)
@SkipThrottle()
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
teamChildCollectionsSorted(
@Args({
name: 'teamID',
description: 'ID of the team to listen to',
type: () => ID,
})
teamID: string,
) {
return this.pubSub.asyncIterator(`team_coll_child/${teamID}/sorted`);
}
}

View file

@ -0,0 +1,74 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SortService } from './sort.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { SortOptions } from 'src/types/SortOptions';
import * as E from 'fp-ts/Either';
import { UserCollection } from 'src/user-collection/user-collections.model';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { SkipThrottle } from '@nestjs/throttler';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { UserCollectionSortData } from './sort.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => UserCollection)
export class SortUserCollectionResolver {
constructor(
private readonly sortService: SortService,
private readonly pubSub: PubSubService,
) {}
// Mutations
@Mutation(() => Boolean, {
description: 'Sort user collections',
})
@UseGuards(GqlAuthGuard)
async sortUserCollections(
@GqlUser() user: AuthUser,
@Args({
name: 'parentCollectionID',
description: 'ID of the parent collection',
type: () => ID,
nullable: true,
})
parentCollectionID: string | null,
@Args({
name: 'sortOption',
description: 'Sorting option',
type: () => SortOptions,
})
sortOption: SortOptions,
): Promise<boolean> {
const result = await this.sortService.sortUserCollections(
user.uid,
parentCollectionID,
sortOption,
);
if (E.isLeft(result)) return false;
return true;
}
// Subscriptions
@Subscription(() => UserCollectionSortData, {
description: 'Listen for User Root Collection Sort Events',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
userRootCollectionsSorted(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll_root/${user.uid}/sorted`);
}
@Subscription(() => UserCollectionSortData, {
description: 'Listen for User Child Collection Sort Events',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
userChildCollectionsSorted(@GqlUser() user: AuthUser) {
return this.pubSub.asyncIterator(`user_coll_child/${user.uid}/sorted`);
}
}

View file

@ -0,0 +1,16 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { SortOptions } from 'src/types/SortOptions';
@ObjectType()
export class UserCollectionSortData {
@Field(() => ID, {
description: 'ID of the parent collection',
nullable: true,
})
parentCollectionID: string;
@Field(() => SortOptions, {
description: 'Sorting option',
})
sortOption: SortOptions;
}

View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { SortTeamCollectionResolver } from './sort-team-collection.resolver';
import { SortService } from './sort.service';
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
import { TeamRequestModule } from 'src/team-request/team-request.module';
import { SortUserCollectionResolver } from './sort-user-collection.resolver';
import { UserCollectionModule } from 'src/user-collection/user-collection.module';
import { UserRequestModule } from 'src/user-request/user-request.module';
import { TeamModule } from 'src/team/team.module';
@Module({
imports: [
UserCollectionModule,
UserRequestModule,
TeamModule,
TeamCollectionModule,
TeamRequestModule,
],
providers: [
SortUserCollectionResolver,
SortTeamCollectionResolver,
SortService,
],
})
export class SortModule {}

View file

@ -0,0 +1,180 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { TeamRequestService } from 'src/team-request/team-request.service';
import { UserRequestService } from 'src/user-request/user-request.service';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { SortService } from './sort.service';
import { SortOptions } from 'src/types/SortOptions';
import * as E from 'fp-ts/Either';
import {
TEAM_COL_REORDERING_FAILED,
TEAM_REQ_REORDERING_FAILED,
} from 'src/errors';
const mockUserRequestService = mockDeep<UserRequestService>();
const mockUserCollectionService = mockDeep<UserCollectionService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockTeamRequestService = mockDeep<TeamRequestService>();
const mockPubSub = mockDeep<PubSubService>();
const sortService = new SortService(
mockUserCollectionService,
mockUserRequestService,
mockTeamCollectionService,
mockTeamRequestService,
mockPubSub,
);
beforeEach(() => {
mockPubSub.publish.mockClear();
});
describe('sortTeamCollections', () => {
it('should return left if teamCollectionService.sortTeamCollections fails', async () => {
mockTeamCollectionService.sortTeamCollections.mockResolvedValue(
E.left(TEAM_COL_REORDERING_FAILED),
);
const result = await sortService.sortTeamCollections(
'teamID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left(TEAM_COL_REORDERING_FAILED));
expect(mockTeamCollectionService.sortTeamCollections).toHaveBeenCalledWith(
'teamID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
});
it('should return left if teamRequestService.sortTeamRequests fails', async () => {
mockTeamCollectionService.sortTeamCollections.mockResolvedValue(
E.right(true),
);
mockTeamRequestService.sortTeamRequests.mockResolvedValue(
E.left(TEAM_REQ_REORDERING_FAILED),
);
const result = await sortService.sortTeamCollections(
'teamID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left(TEAM_REQ_REORDERING_FAILED));
expect(mockTeamRequestService.sortTeamRequests).toHaveBeenCalledWith(
'teamID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
});
it('should publish root event if parentCollectionID is falsy', async () => {
mockTeamCollectionService.sortTeamCollections.mockResolvedValue(
E.right(true),
);
mockTeamRequestService.sortTeamRequests.mockResolvedValue(E.right(true));
const result = await sortService.sortTeamCollections(
'teamID',
null,
SortOptions.TITLE_ASC,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll_root/teamID/sorted`,
true,
);
expect(result).toEqual(E.right(true));
});
it('should publish child event if parentCollectionID is truthy', async () => {
mockTeamCollectionService.sortTeamCollections.mockResolvedValue(
E.right(true),
);
mockTeamRequestService.sortTeamRequests.mockResolvedValue(E.right(true));
const result = await sortService.sortTeamCollections(
'teamID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_coll_child/teamID/sorted`,
'parentCollectionID',
);
expect(result).toEqual(E.right(true));
});
});
describe('sortUserCollections', () => {
it('should return left if userCollectionService.sortUserCollections fails', async () => {
mockUserCollectionService.sortUserCollections.mockResolvedValue(
E.left('user_coll/reordering_failed'),
);
const result = await sortService.sortUserCollections(
'userID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left('user_coll/reordering_failed'));
expect(mockUserCollectionService.sortUserCollections).toHaveBeenCalledWith(
'userID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
});
it('should return left if userRequestService.sortUserRequests fails', async () => {
mockUserCollectionService.sortUserCollections.mockResolvedValue(
E.right(true),
);
mockUserRequestService.sortUserRequests.mockResolvedValue(
E.left('user_coll/reordering_failed'),
);
const result = await sortService.sortUserCollections(
'userID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left('user_coll/reordering_failed'));
expect(mockUserRequestService.sortUserRequests).toHaveBeenCalledWith(
'userID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
});
it('should publish root event if parentCollectionID is falsy', async () => {
mockUserCollectionService.sortUserCollections.mockResolvedValue(
E.right(true),
);
mockUserRequestService.sortUserRequests.mockResolvedValue(E.right(true));
const result = await sortService.sortUserCollections(
'userID',
null,
SortOptions.TITLE_ASC,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`user_coll_root/userID/sorted`,
{
parentCollectionID: null,
sortOption: SortOptions.TITLE_ASC,
},
);
expect(result).toEqual(E.right(true));
});
it('should publish child event if parentCollectionID is truthy', async () => {
mockUserCollectionService.sortUserCollections.mockResolvedValue(
E.right(true),
);
mockUserRequestService.sortUserRequests.mockResolvedValue(E.right(true));
const result = await sortService.sortUserCollections(
'userID',
'parentCollectionID',
SortOptions.TITLE_ASC,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`user_coll_child/userID/sorted`,
{
parentCollectionID: 'parentCollectionID',
sortOption: SortOptions.TITLE_ASC,
},
);
expect(result).toEqual(E.right(true));
});
});

View file

@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import * as E from 'fp-ts/Either';
import { SortOptions } from 'src/types/SortOptions';
import { TeamRequestService } from 'src/team-request/team-request.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { UserRequestService } from 'src/user-request/user-request.service';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
@Injectable()
export class SortService {
constructor(
private readonly userCollectionService: UserCollectionService,
private readonly userRequestService: UserRequestService,
private readonly teamCollectionService: TeamCollectionService,
private readonly teamRequestService: TeamRequestService,
private readonly pubsub: PubSubService,
) {}
async sortTeamCollections(
teamID: string,
parentCollectionID: string,
sortOption: SortOptions,
) {
const isCollectionSorted =
await this.teamCollectionService.sortTeamCollections(
teamID,
parentCollectionID,
sortOption,
);
if (E.isLeft(isCollectionSorted)) return E.left(isCollectionSorted.left);
const isRequestSorted = await this.teamRequestService.sortTeamRequests(
teamID,
parentCollectionID,
sortOption,
);
if (E.isLeft(isRequestSorted)) return E.left(isRequestSorted.left);
// Publish the sort event
if (!parentCollectionID) {
this.pubsub.publish(`team_coll_root/${teamID}/sorted`, true);
} else {
this.pubsub.publish(
`team_coll_child/${teamID}/sorted`,
parentCollectionID,
);
}
return E.right(true);
}
async sortUserCollections(
userID: string,
parentCollectionID: string,
sortOption: SortOptions,
) {
const isCollectionSorted =
await this.userCollectionService.sortUserCollections(
userID,
parentCollectionID,
sortOption,
);
if (E.isLeft(isCollectionSorted)) return E.left(isCollectionSorted.left);
const isRequestSorted = await this.userRequestService.sortUserRequests(
userID,
parentCollectionID,
sortOption,
);
if (E.isLeft(isRequestSorted)) return E.left(isRequestSorted.left);
// Publish the sort event
if (!parentCollectionID) {
this.pubsub.publish(`user_coll_root/${userID}/sorted`, {
parentCollectionID,
sortOption,
});
} else {
this.pubsub.publish(`user_coll_child/${userID}/sorted`, {
parentCollectionID,
sortOption,
});
}
return E.right(true);
}
}

View file

@ -28,6 +28,7 @@ import {
UserCollectionReorderData,
} from 'src/user-collection/user-collections.model';
import { Shortcode } from 'src/shortcode/shortcode.model';
import { UserCollectionSortData } from 'src/orchestration/sort/sort.model';
// A custom message type that defines the topic and the corresponding payload.
// For every module that publishes a subscription add its type def and the possible subscription type.
@ -53,6 +54,8 @@ export type TopicDef = {
[topic: `user_coll/${string}/${'duplicated'}`]: UserCollectionDuplicatedData;
[topic: `user_coll/${string}/${'deleted'}`]: UserCollectionRemovedData;
[topic: `user_coll/${string}/${'order_updated'}`]: UserCollectionReorderData;
[topic: `user_coll_root/${string}/${'sorted'}`]: UserCollectionSortData;
[topic: `user_coll_child/${string}/${'sorted'}`]: UserCollectionSortData;
[topic: `team/${string}/member_removed`]: string;
[topic: `team/${string}/${'member_added' | 'member_updated'}`]: TeamMember;
[
@ -64,6 +67,8 @@ export type TopicDef = {
[topic: `team_coll/${string}/${'coll_removed'}`]: string;
[topic: `team_coll/${string}/${'coll_moved'}`]: TeamCollection;
[topic: `team_coll/${string}/${'coll_order_updated'}`]: CollectionReorderData;
[topic: `team_coll_root/${string}/${'sorted'}`]: boolean;
[topic: `team_coll_child/${string}/${'sorted'}`]: string;
[
topic: `team_req/${string}/${'req_created' | 'req_updated' | 'req_moved'}`
]: TeamRequest;

View file

@ -23,6 +23,7 @@ import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamService } from 'src/team/team.service';
import { SortOptions } from 'src/types/SortOptions';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@ -1518,6 +1519,75 @@ describe('updateTeamCollection', () => {
});
});
describe('sortTeamCollections', () => {
it('should sort collections by TITLE_ASC', async () => {
const parentID = null;
const teamID = team.id;
mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma));
mockPrisma.acquireLocks.mockResolvedValue(undefined);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce(
rootTeamCollectionList,
);
const result = await teamCollectionService.sortTeamCollections(
teamID,
parentID,
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.right(true));
expect(mockPrisma.teamCollection.findMany).toHaveBeenCalledWith({
where: { teamID, parentID },
orderBy: { title: 'asc' },
select: { id: true },
});
expect(mockPrisma.teamCollection.update).toHaveBeenCalledTimes(
rootTeamCollectionList.length,
);
});
it('should sort collections by TITLE_DESC', async () => {
const parentID = null;
const teamID = team.id;
mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma));
mockPrisma.acquireLocks.mockResolvedValue(undefined);
mockPrisma.teamCollection.findMany.mockResolvedValueOnce(
rootTeamCollectionList,
);
const result = await teamCollectionService.sortTeamCollections(
teamID,
parentID,
SortOptions.TITLE_DESC,
);
expect(result).toEqual(E.right(true));
expect(mockPrisma.teamCollection.findMany).toHaveBeenCalledWith({
where: { teamID, parentID },
orderBy: { title: 'desc' },
select: { id: true },
});
expect(mockPrisma.teamCollection.update).toHaveBeenCalledTimes(
rootTeamCollectionList.length,
);
});
it('should return left(TEAM_COL_REORDERING_FAILED) on error', async () => {
const parentID = null;
const teamID = team.id;
mockPrisma.$transaction.mockRejectedValueOnce(new Error('fail'));
const result = await teamCollectionService.sortTeamCollections(
teamID,
parentID,
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left(TEAM_COL_REORDERING_FAILED));
});
});
//ToDo: write test cases for exportCollectionsToJSON
describe('getCollectionForCLI', () => {

View file

@ -46,6 +46,7 @@ import {
import { RESTError } from 'src/types/RESTError';
import { TeamService } from 'src/team/team.service';
import { PrismaError } from 'src/prisma/prisma-error-codes';
import { SortOptions } from 'src/types/SortOptions';
@Injectable()
export class TeamCollectionService {
@ -1467,4 +1468,57 @@ export class TeamCollectionService {
return E.right(true);
}
/**
* Sort Team Collections in a parent collection
*
* @param teamID The Team ID
* @param parentID The Parent Collection ID
* @param sortBy The sort option
* @returns Boolean of sorting status
*/
async sortTeamCollections(
teamID: string,
parentID: string,
sortBy: SortOptions,
) {
// Handle all sort options, including a default
let orderBy: Prisma.Enumerable<Prisma.TeamCollectionOrderByWithRelationInput>;
if (sortBy === SortOptions.TITLE_ASC) {
orderBy = { title: 'asc' };
} else if (sortBy === SortOptions.TITLE_DESC) {
orderBy = { title: 'desc' };
} else {
orderBy = { orderIndex: 'asc' };
}
try {
await this.prisma.$transaction(async (tx) => {
await this.prisma.acquireLocks(tx, 'TeamCollection', null, parentID);
const collections = await tx.teamCollection.findMany({
where: { teamID, parentID },
orderBy,
select: { id: true },
});
// Update the orderIndex of each collection based on the new order
const promises = collections.map((collection, i) =>
tx.teamCollection.update({
where: { id: collection.id },
data: { orderIndex: i + 1 },
}),
);
await Promise.all(promises);
});
} catch (error) {
console.error(
'Error from TeamCollectionService.sortTeamCollections',
error,
);
return E.left(TEAM_COL_REORDERING_FAILED);
}
return E.right(true);
}
}

View file

@ -20,6 +20,7 @@ import {
TeamCollection as DbTeamCollection,
} from '@prisma/client';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { SortOptions } from 'src/types/SortOptions';
const mockPrisma = mockDeep<PrismaService>();
const mockTeamService = mockDeep<TeamService>();
@ -711,6 +712,7 @@ describe('moveRequest', () => {
).resolves.toEqualLeft(TEAM_REQ_REORDERING_FAILED);
});
});
describe('totalRequestsInATeam', () => {
test('should resolve right and return a total team reqs count ', async () => {
mockPrisma.teamRequest.count.mockResolvedValueOnce(2);
@ -732,13 +734,90 @@ describe('totalRequestsInATeam', () => {
});
expect(result).toEqual(0);
});
});
describe('getTeamRequestsCount', () => {
test('should return count of all Team Collections in the organization', async () => {
mockPrisma.teamRequest.count.mockResolvedValueOnce(10);
describe('getTeamRequestsCount', () => {
test('should return count of all Team Collections in the organization', async () => {
mockPrisma.teamRequest.count.mockResolvedValueOnce(10);
const result = await teamRequestService.getTeamRequestsCount();
expect(result).toEqual(10);
});
const result = await teamRequestService.getTeamRequestsCount();
expect(result).toEqual(10);
});
});
describe('sortTeamRequests', () => {
test('should resolve right if collectionID is null', async () => {
const teamID = team.id;
const result = await teamRequestService.sortTeamRequests(
teamID,
null,
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.right(true));
});
test('should resolve right and sorts team requests by TITLE_ASC', async () => {
const teamID = team.id;
const collectionID = teamCollection.id;
mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma));
mockPrisma.acquireLocks.mockResolvedValue(undefined);
mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests);
const result = await teamRequestService.sortTeamRequests(
teamID,
collectionID,
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.right(true));
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockPrisma.teamRequest.findMany).toHaveBeenCalledWith({
where: { teamID, collectionID },
orderBy: { title: 'asc' },
select: { id: true },
});
expect(mockPrisma.teamRequest.update).toHaveBeenCalledTimes(
dbTeamRequests.length,
);
});
test('should resolve right and sorts team requests by TITLE_DESC', async () => {
const teamID = team.id;
const collectionID = teamCollection.id;
mockPrisma.$transaction.mockImplementation(async (cb) => cb(mockPrisma));
mockPrisma.acquireLocks.mockResolvedValue(undefined);
mockPrisma.teamRequest.findMany.mockResolvedValue(dbTeamRequests);
const result = await teamRequestService.sortTeamRequests(
teamID,
collectionID,
SortOptions.TITLE_DESC,
);
expect(result).toEqual(E.right(true));
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockPrisma.teamRequest.findMany).toHaveBeenCalledWith({
where: { teamID, collectionID },
orderBy: { title: 'desc' },
select: { id: true },
});
expect(mockPrisma.teamRequest.update).toHaveBeenCalledTimes(
dbTeamRequests.length,
);
});
test('should returns left(TEAM_REQ_REORDERING_FAILED) on error', async () => {
const teamID = team.id;
const collectionID = teamCollection.id;
mockPrisma.$transaction.mockRejectedValue(new Error('fail'));
const result = await teamRequestService.sortTeamRequests(
teamID,
collectionID,
SortOptions.TITLE_ASC,
);
expect(result).toEqual(E.left(TEAM_REQ_REORDERING_FAILED));
});
});

View file

@ -16,6 +16,7 @@ import { stringToJson } from 'src/utils';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { Prisma, TeamRequest as DbTeamRequest } from '@prisma/client';
import { SortOptions } from 'src/types/SortOptions';
@Injectable()
export class TeamRequestService {
@ -502,4 +503,57 @@ export class TeamRequestService {
const teamRequestsCount = this.prisma.teamRequest.count();
return teamRequestsCount;
}
/**
* Sort Team Requests in a Collection based on the Sort Option
*
* @param teamID The Team ID
* @param collectionID The Collection ID
* @param sortOption The Sort Option
* @returns An Either of a Boolean if the sorting operation was successful
*/
async sortTeamRequests(
teamID: string,
collectionID: string,
sortBy: SortOptions,
) {
if (!collectionID) return E.right(true); // No sorting for requests in root collection
let orderBy: Prisma.Enumerable<Prisma.TeamRequestOrderByWithRelationInput>;
if (sortBy === SortOptions.TITLE_ASC) {
orderBy = { title: 'asc' };
} else if (sortBy === SortOptions.TITLE_DESC) {
orderBy = { title: 'desc' };
} else {
orderBy = { orderIndex: 'asc' };
}
try {
await this.prisma.$transaction(async (tx) => {
// lock the rows
await this.prisma.acquireLocks(tx, 'TeamRequest', null, null, [
collectionID,
]);
const teamRequests = await tx.teamRequest.findMany({
where: { teamID, collectionID },
orderBy,
select: { id: true },
});
// Update the orderIndex of each request based on the new order (parallel)
const promises = teamRequests.map((request, i) =>
tx.teamRequest.update({
where: { id: request.id },
data: { orderIndex: i + 1 },
}),
);
await Promise.all(promises);
});
} catch (error) {
console.error('Error from TeamRequestService.sortTeamRequests', error);
return E.left(TEAM_REQ_REORDERING_FAILED);
}
return E.right(true);
}
}

View file

@ -0,0 +1,10 @@
import { registerEnumType } from '@nestjs/graphql';
export enum SortOptions {
TITLE_ASC = 'TITLE_ASC',
TITLE_DESC = 'TITLE_DESC',
}
registerEnumType(SortOptions, {
name: 'SortOptions',
});

View file

@ -35,6 +35,7 @@ import {
} from 'src/utils';
import { CollectionFolder } from 'src/types/CollectionFolder';
import { PrismaError } from 'src/prisma/prisma-error-codes';
import { SortOptions } from 'src/types/SortOptions';
@Injectable()
export class UserCollectionService {
@ -1312,4 +1313,54 @@ export class UserCollectionService {
requests: transformedRequests,
});
}
/**
* Sort collections in a parent collection
* @param userID The User UID
* @param parentID The ID of the parent collection or null for root collections
* @param sortBy The sorting option
* @returns An Either of a Boolean if the sorting operation was successful
*/
async sortUserCollections(
userID: string,
parentID: string | null,
sortBy: SortOptions,
) {
// Handle all sort options, including a default
let orderBy: Prisma.Enumerable<Prisma.UserCollectionOrderByWithRelationInput>;
if (sortBy === SortOptions.TITLE_ASC) {
orderBy = { title: 'asc' };
} else if (sortBy === SortOptions.TITLE_DESC) {
orderBy = { title: 'desc' };
} else {
orderBy = { orderIndex: 'asc' };
}
try {
await this.prisma.$transaction(async (tx) => {
await this.prisma.acquireLocks(tx, 'UserCollection', userID, parentID);
const collections = await tx.userCollection.findMany({
where: { userUid: userID, parentID },
orderBy,
select: { id: true },
});
const promises = collections.map((coll, index) =>
tx.userCollection.update({
where: { id: coll.id },
data: { orderIndex: index + 1 },
}),
);
await Promise.all(promises);
});
} catch (error) {
console.error('Error from UserCollectionService.sortUserCollections:', {
error,
});
return E.left(USER_COLL_REORDERING_FAILED);
}
return E.right(true);
}
}

View file

@ -11,5 +11,6 @@ import { UserRequestService } from './user-request.service';
UserRequestUserCollectionResolver,
UserRequestService,
],
exports: [UserRequestService],
})
export class UserRequestModule {}

View file

@ -3,7 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { PubSubService } from '../pubsub/pubsub.service';
import * as E from 'fp-ts/Either';
import { UserRequest } from './user-request.model';
import { UserRequest as DbUserRequest } from '@prisma/client';
import { Prisma, UserRequest as DbUserRequest } from '@prisma/client';
import {
USER_COLLECTION_NOT_FOUND,
USER_REQUEST_CREATION_FAILED,
@ -15,6 +15,7 @@ import { stringToJson } from 'src/utils';
import { AuthUser } from 'src/types/AuthUser';
import { ReqType } from 'src/types/RequestTypes';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { SortOptions } from 'src/types/SortOptions';
@Injectable()
export class UserRequestService {
@ -486,4 +487,56 @@ export class UserRequestService {
return E.left(USER_REQUEST_REORDERING_FAILED);
}
}
/**
* Sort user requests inside a collection
* @param userUid UID of the user who owns the collection
* @param collectionID ID of the collection to which the requests belong
* @param sortBy Sorting option
* @returns Either of a boolean
*/
async sortUserRequests(
userUid: string,
collectionID: string,
sortBy: SortOptions,
): Promise<E.Left<string> | E.Right<boolean>> {
if (!collectionID) return E.right(true);
let orderBy: Prisma.Enumerable<Prisma.UserRequestOrderByWithRelationInput>;
if (sortBy === SortOptions.TITLE_ASC) {
orderBy = { title: 'asc' };
} else if (sortBy === SortOptions.TITLE_DESC) {
orderBy = { title: 'desc' };
} else {
orderBy = { orderIndex: 'asc' };
}
try {
await this.prisma.$transaction(async (tx) => {
await this.prisma.acquireLocks(tx, 'UserRequest', userUid, null, [
collectionID,
]);
const userRequests = await tx.userRequest.findMany({
where: { userUid, collectionID },
orderBy,
select: { id: true },
});
// Update the orderIndex of each request based on the new order (parallel)
const promises = userRequests.map((request, i) =>
tx.userRequest.update({
where: { id: request.id },
data: { orderIndex: i + 1 },
}),
);
await Promise.all(promises);
});
} catch (error) {
console.error('Error from UserRequestService.sortUserRequests', error);
return E.left(USER_REQUEST_REORDERING_FAILED);
}
return E.right(true);
}
}

View file

@ -58,6 +58,7 @@
"send": "Send",
"share": "Share",
"show_secret": "Show secret",
"sort": "Sort",
"start": "Start",
"starting": "Starting",
"stop": "Stop",
@ -344,6 +345,7 @@
"save_to_collection": "Save to Collection",
"select": "Select a Collection",
"select_location": "Select location",
"sorted": "Collection sorted",
"details": "Details",
"duplicated": "Collection duplicated"
},
@ -604,7 +606,8 @@
"name_length_insufficient": "Folder name should be at least 3 characters long",
"new": "New Folder",
"run": "Run Folder",
"renamed": "Folder renamed"
"renamed": "Folder renamed",
"sorted": "Folder sorted"
},
"graphql": {
"arguments": "Arguments",

View file

@ -58,7 +58,13 @@
</span>
</span>
</div>
<div class="flex">
<div
v-if="isCollectionLoading && !isOpen"
class="flex items-center px-2"
>
<HoppSmartSpinner />
</div>
<div v-else class="flex">
<HoppButtonSecondary
v-if="!hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
@ -76,6 +82,7 @@
@click="emit('add-folder')"
/>
<HoppButtonSecondary
v-if="!isEmpty"
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlaySquare"
:title="t('collection_runner.run_collection')"
@ -108,6 +115,7 @@
@keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()"
@keyup.t="runCollectionAction?.$el.click()"
@keyup.s="sortAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
@ -137,6 +145,7 @@
"
/>
<HoppSmartItem
v-if="!isEmpty"
ref="runCollectionAction"
:icon="IconPlaySquare"
:label="t('collection_runner.run_collection')"
@ -161,6 +170,19 @@
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess && isChildrenSortable"
ref="sortAction"
:icon="IconArrowUpDown"
:label="t('action.sort')"
:shortcut="['S']"
@click="
() => {
sortCollection()
hide()
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess"
ref="duplicateAction"
@ -201,6 +223,7 @@
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess"
ref="deleteAction"
@ -261,6 +284,9 @@ import IconMoreVertical from "~icons/lucide/more-vertical"
import IconPlaySquare from "~icons/lucide/play-square"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
import IconArrowUpDown from "~icons/lucide/arrow-up-down"
import { CurrentSortValuesService } from "~/services/current-sort.service"
import { useService } from "dioc/vue"
type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder"
@ -285,6 +311,7 @@ const props = withDefaults(
collectionMoveLoading?: string[]
isLastItem?: boolean
duplicateCollectionLoading?: boolean
teamLoadingCollections?: string[]
}>(),
{
id: "",
@ -296,7 +323,9 @@ const props = withDefaults(
exportLoading: false,
hasNoTeamAccess: false,
isLastItem: false,
duplicateLoading: false,
duplicateCollectionLoading: false,
collectionMoveLoading: () => [],
teamLoadingCollections: () => [],
}
)
@ -316,6 +345,14 @@ const emit = defineEmits<{
(event: "update-collection-order", payload: DataTransfer): void
(event: "update-last-collection-order", payload: DataTransfer): void
(event: "run-collection", collectionID: string): void
(
event: "sort-collections",
payload: {
collectionID: string
sortOrder: "asc" | "desc"
collectionRefID: string
}
): void
}>()
const tippyActions = ref<HTMLDivElement | null>(null)
@ -328,18 +365,80 @@ const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const propertiesAction = ref<HTMLButtonElement | null>(null)
const runCollectionAction = ref<HTMLButtonElement | null>(null)
const sortAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const orderingLastItem = ref(false)
const dropItemID = ref("")
/**
* Determines if the collection/folder is empty.
* A collection/folder is considered empty if it has no requests and no child folders.
*/
const isEmpty = computed(() => {
if (!props.data) return true
if (props.collectionsType === "my-collections") {
const collection = props.data as HoppCollection
const req = collection.requests.length
const fol = collection.folders.length
return req === 0 && fol === 0
}
const teamCollection = props.data as TeamCollection
const req = teamCollection.requests?.length ?? 0
const child = teamCollection.children?.length ?? 0
return req === 0 && child === 0
})
/**
* Determines if the collection/folder is sortable.
* A collection/folder is sortable if it has more than one request or more than one child folder.
* or one request and one child folder.
*/
const isChildrenSortable = computed(() => {
if (!props.data) return false
if (props.collectionsType === "my-collections") {
const collection = props.data as HoppCollection
const req = collection.requests.length
const fol = collection.folders.length
return req > 1 || fol > 1 || (req === 1 && fol === 1)
}
const teamCollection = props.data as TeamCollection
const req = teamCollection.requests?.length ?? 0
const child = teamCollection.children?.length ?? 0
return req > 1 || child > 1 || (req === 1 && child === 1)
})
const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
type: "collection",
id: "",
parentID: "",
})
const currentSortValuesService = useService(CurrentSortValuesService)
const collectionRefID = computed(() => {
return props.collectionsType === "my-collections"
? (props.data as HoppCollection)._ref_id
: props.id
})
const currentSortOrder = ref<"asc" | "desc">(
currentSortValuesService.getSortOption(collectionRefID.value ?? "personal")
?.sortOrder ?? "asc"
)
const isCollectionLoading = computed(() => {
return props.teamLoadingCollections!.includes(props.id)
})
// Used to determine if the collection is being dragged to a different destination
// This is used to make the highlight effect work
watch(
@ -495,6 +594,16 @@ const isCollLoading = computed(() => {
return false
})
const sortCollection = () => {
currentSortOrder.value = currentSortOrder.value === "asc" ? "desc" : "asc"
emit("sort-collections", {
collectionID: props.id,
sortOrder: currentSortOrder.value,
collectionRefID: collectionRefID.value ?? "personal",
})
}
const resetDragState = () => {
dragging.value = false
ordering.value = false

View file

@ -22,6 +22,14 @@
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-if="filteredCollections && filteredCollections.length > 1"
v-tippy="{ theme: 'tooltip' }"
blank
:title="t('action.sort')"
:icon="IconArrowUpDown"
@click="debouncedSorting"
/>
<HoppButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
@ -96,6 +104,7 @@
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@sort-collections="emit('sort-collections', $event)"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="
@ -184,7 +193,12 @@
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-folder', node.id)"
@remove-collection="
node.data.type === 'folders' && emit('remove-folder', node.id)
"
@sort-collections="
node.data.type === 'folders' && emit('sort-collections', $event)
"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="
@ -225,7 +239,7 @@
:is-active="
isActiveRequest(
node.data.data.parentIndex,
parseInt(pathToIndex(node.id))
node.data.data.data._ref_id ?? node.data.data.data.id
)
"
:is-selected="
@ -310,6 +324,8 @@
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.id,
requestRefID:
node.data.data.data._ref_id ?? node.data.data.data.id,
})
"
@update-request-order="
@ -402,8 +418,9 @@
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import IconArrowUpDown from "~icons/lucide/arrow-up-down"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { computed, PropType, Ref, toRef } from "vue"
import { computed, PropType, ref, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui/helpers"
import { useI18n } from "@composables/i18n"
@ -413,6 +430,8 @@ import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { useDebounceFn } from "@vueuse/core"
import { CurrentSortValuesService } from "~/services/current-sort.service"
export type Collection = {
type: "collections"
@ -577,6 +596,14 @@ const emit = defineEmits<{
isActive: boolean
}
): void
(
event: "sort-collections",
payload: {
collectionID: string | null
sortOrder: "asc" | "desc"
collectionRefID: string
}
): void
(
event: "share-request",
payload: {
@ -589,6 +616,7 @@ const emit = defineEmits<{
folderPath: string
requestIndex: string
destinationCollectionIndex: string
requestRefID?: string
}
): void
(
@ -623,6 +651,12 @@ const emit = defineEmits<{
const refFilterCollection = toRef(props, "filteredCollections")
const currentSortValuesService = useService(CurrentSortValuesService)
const currentSortOrder = ref<"asc" | "desc">(
currentSortValuesService.getSortOption("personal")?.sortOrder ?? "asc"
)
const pathToIndex = (path: string) => {
const pathArr = path.split("/")
return pathArr[pathArr.length - 1]
@ -659,9 +693,15 @@ const isSelected = ({
}
const tabs = useService(RESTTabService)
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
const active = computed(
() =>
tabs.currentActiveTab.value.document.type !== "test-runner" &&
tabs.currentActiveTab.value.document.saveContext
)
const isActiveRequest = (folderPath: string, requestRefID: string) => {
if (active.value === null || !active.value) return false
const isActiveRequest = (folderPath: string, requestIndex: number) => {
return pipe(
active.value,
O.fromNullable,
@ -669,7 +709,7 @@ const isActiveRequest = (folderPath: string, requestIndex: number) => {
(active) =>
active.originLocation === "user-collection" &&
active.folderPath === folderPath &&
active.requestIndex === requestIndex &&
active.requestRefID === requestRefID &&
active.exampleID === undefined
),
O.isSome
@ -694,7 +734,10 @@ const selectRequest = (data: {
request,
folderPath,
requestIndex,
isActive: isActiveRequest(folderPath, parseInt(requestIndex)),
isActive: isActiveRequest(
folderPath,
request._ref_id ?? request.id ?? ""
),
})
}
}
@ -708,11 +751,13 @@ const dragRequest = (
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
requestRefID,
}: { folderPath: string | null; requestIndex: string; requestRefID?: string }
) => {
if (!folderPath) return
dataTransfer.setData("folderPath", folderPath)
dataTransfer.setData("requestIndex", requestIndex)
if (requestRefID) dataTransfer.setData("requestRefID", requestRefID)
}
const dropEvent = (
@ -722,12 +767,14 @@ const dropEvent = (
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
const requestRefID = dataTransfer.getData("requestRefID")
if (folderPath && requestIndex) {
emit("drop-request", {
folderPath,
requestIndex,
destinationCollectionIndex,
requestRefID,
})
} else {
emit("drop-collection", {
@ -771,6 +818,20 @@ const updateCollectionOrder = (
})
}
const debouncedSorting = useDebounceFn(() => {
sortCollection()
}, 250)
const sortCollection = () => {
currentSortOrder.value = currentSortOrder.value === "asc" ? "desc" : "asc"
emit("sort-collections", {
collectionID: null,
sortOrder: currentSortOrder.value,
collectionRefID: "personal",
})
}
type MyCollectionNode = Collection | Folder | Requests
class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {

View file

@ -32,6 +32,20 @@
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-if="teamCollectionList && teamCollectionList.length > 1"
v-tippy="{ theme: 'tooltip' }"
:disabled="
(collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined) ||
isShowingSearchResults ||
hasNoTeamAccess
"
blank
:title="t('action.sort')"
:icon="IconArrowUpDown"
@click="debouncedSorting"
/>
<HoppButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
@ -40,8 +54,8 @@
collectionsType.selectedTeam === undefined) ||
isShowingSearchResults
"
:icon="IconImport"
:title="t('modal.import_export')"
:icon="IconImport"
@click="emit('display-modal-import-export')"
/>
</span>
@ -58,6 +72,7 @@
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:team-loading-collections="teamLoadingCollections"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
@ -108,6 +123,7 @@
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@sort-collections="emit('sort-collections', $event)"
@drop-event="dropEvent($event, node.id, getPath(node.id, false))"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="
@ -128,12 +144,12 @@
"
@toggle-children="
() => {
;(toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-collection',
collectionID: node.id,
}))
;(saveRequest &&
emit('select', {
pickedType: 'teams-collection',
collectionID: node.id,
}),
toggleChildren())
}
"
@run-collection="
@ -159,6 +175,7 @@
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:team-loading-collections="teamLoadingCollections"
:has-no-team-access="hasNoTeamAccess || isShowingSearchResults"
:collection-move-loading="collectionMoveLoading"
:duplicate-collection-loading="duplicateCollectionLoading"
@ -210,6 +227,9 @@
node.data.type === 'folders' &&
emit('remove-folder', node.data.data.data.id)
"
@sort-collections="
node.data.type === 'folders' && emit('sort-collections', $event)
"
@drop-event="
dropEvent($event, node.data.data.data.id, getPath(node.id, false))
"
@ -250,11 +270,12 @@
"
@click="
() => {
handleCollectionClick({
// for the folders, we get a path, so we need to get the last part of the path which is the folder id
collectionID: node.id.split('/').pop() as string,
isOpen,
})
node.data.type === 'folders' &&
handleCollectionClick({
// for the folders, we get a path, so we need to get the last part of the path which is the folder id
collectionID: node.id.split('/').pop() as string,
isOpen,
})
}
"
/>
@ -452,7 +473,9 @@
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { computed, PropType, Ref, toRef } from "vue"
import IconArrowUpDown from "~icons/lucide/arrow-up-down"
import { computed, PropType, ref, Ref, toRef } from "vue"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
@ -466,6 +489,8 @@ import { Picked } from "~/helpers/types/HoppPicked.js"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
import { TeamWorkspace } from "~/services/workspace.service"
import { useDebounceFn } from "@vueuse/core"
import { CurrentSortValuesService } from "~/services/current-sort.service"
const t = useI18n()
const colorMode = useColorMode()
@ -631,12 +656,21 @@ const emit = defineEmits<{
request: HoppRESTRequest
}
): void
(
event: "sort-collections",
payload: {
collectionID: string | null
sortOrder: "asc" | "desc"
collectionRefID: string
}
): void
(
event: "drop-request",
payload: {
folderPath: string
requestIndex: string
destinationCollectionIndex: string
destinationParentPath?: string
}
): void
(
@ -684,6 +718,17 @@ const emit = defineEmits<{
): void
}>()
const currentSortValuesService = useService(CurrentSortValuesService)
const teamID = computed(() => {
return props.collectionsType.selectedTeam?.teamID
})
const currentSortOrder = ref<"asc" | "desc">(
currentSortValuesService.getSortOption(teamID.value ?? "personal")
?.sortOrder ?? "asc"
)
const getPath = (path: string, pop: boolean = true) => {
const pathArray = path.split("/")
if (pop) pathArray.pop()
@ -740,9 +785,14 @@ const isSelected = ({
)
}
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
const active = computed(
() =>
tabs.currentActiveTab.value.document.type !== "test-runner" &&
tabs.currentActiveTab.value.document.saveContext
)
const isActiveRequest = (requestID: string) => {
if (!active.value) return false
return pipe(
active.value,
O.fromNullable,
@ -807,12 +857,12 @@ const dropEvent = (
const requestIndex = dataTransfer.getData("requestIndex")
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
const currentParentIndex = dataTransfer.getData("parentIndex")
if (folderPath && requestIndex) {
emit("drop-request", {
folderPath,
requestIndex,
destinationCollectionIndex,
destinationParentPath,
})
} else {
emit("drop-collection", {
@ -858,6 +908,20 @@ const updateCollectionOrder = (
})
}
const debouncedSorting = useDebounceFn(() => {
sortCollection()
}, 250)
const sortCollection = () => {
currentSortOrder.value = currentSortOrder.value === "asc" ? "desc" : "asc"
emit("sort-collections", {
collectionID: null,
sortOrder: currentSortOrder.value,
collectionRefID: teamID.value ?? "personal",
})
}
type TeamCollections = {
isLastItem: boolean
type: "collections"
@ -935,7 +999,7 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
}
const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) &&
if (!props.teamLoadingCollections.includes(parsedID))
emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) {

View file

@ -43,7 +43,7 @@ const toast = useToast()
const props = defineProps<{
show: boolean
folderPath?: string
collectionIndex: number
collectionIndex?: number
}>()
const emit = defineEmits<{

View file

@ -153,7 +153,7 @@
@click="
() => {
emit('edit-properties', {
collectionIndex: String(collectionIndex),
collectionIndex: String(folderPath) ?? '0',
collection: collection,
})
hide()
@ -188,12 +188,7 @@
@duplicate-collection="$emit('duplicate-collection', $event)"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@edit-properties="
$emit('edit-properties', {
collectionIndex: `${collectionIndex}/${String(index)}`,
collection: folder,
})
"
@edit-properties="$emit('edit-properties', $event)"
@select="$emit('select', $event)"
@select-request="$emit('select-request', $event)"
@drop-request="$emit('drop-request', $event)"
@ -275,6 +270,7 @@ const props = defineProps<{
collectionIndex: number | null
collection: HoppCollection
isFiltered: boolean
folderPath: string
}>()
const colorMode = useColorMode()

View file

@ -146,8 +146,8 @@
@click="
() => {
emit('edit-properties', {
collectionIndex: collectionIndex,
collection: collection,
collectionIndex: folderPath,
collection: folder,
})
hide()
}
@ -182,12 +182,7 @@
@duplicate-collection="emit('duplicate-collection', $event)"
@edit-request="emit('edit-request', $event)"
@duplicate-request="emit('duplicate-request', $event)"
@edit-properties="
emit('edit-properties', {
collectionIndex: `${folderPath}/${String(subFolderIndex)}`,
collection: subFolder,
})
"
@edit-properties="emit('edit-properties', $event)"
@select="emit('select', $event)"
@select-request="$emit('select-request', $event)"
/>

View file

@ -46,6 +46,7 @@
:key="`collection-${index}`"
:picked="picked"
:name="collection.name"
:folder-path="String(index)"
:collection-index="index"
:collection="collection"
:is-filtered="filterText.length > 0"
@ -607,8 +608,7 @@ const editProperties = ({
const parentIndex = collectionIndex.split("/").slice(0, -1).join("/") // remove last folder to get parent folder
let inheritedProperties = undefined
if (parentIndex) {
if (parentIndex && parentIndex !== "") {
inheritedProperties = cascadeParentCollectionForProperties(
parentIndex,
"graphql"
@ -633,6 +633,7 @@ const setCollectionProperties = async (newCollection: {
const isValidToken = await handleTokenValidation()
if (!isValidToken) return
const { collection, path, isRootCollection } = newCollection
if (!collection) {
return
}
@ -644,11 +645,7 @@ const setCollectionProperties = async (newCollection: {
}
nextTick(() => {
updateInheritedPropertiesForAffectedRequests(
path,
cascadeParentCollectionForProperties(path, "graphql"),
"graphql"
)
updateInheritedPropertiesForAffectedRequests(path, "graphql")
})
displayModalEditProperties(false)

View file

@ -65,19 +65,21 @@
@select="selectPicked"
@select-response="selectResponse"
@select-request="selectRequest"
@sort-collections="sortCollections"
@update-request-order="updateRequestOrder"
@update-collection-order="updateCollectionOrder"
/>
<CollectionsTeamCollections
v-else
:collections-type="collectionsType"
:team-collection-list="
filterTexts.length > 0 ? teamsSearchResults : teamCollectionList
filterTexts.length > 0 ? teamsSearchResults : teamCollections
"
:team-loading-collections="
filterTexts.length > 0
? collectionsBeingLoadedFromSearch
: teamLoadingCollections
: teamCollectionService.loadingCollections.value
"
:filter-text="filterTexts"
:export-loading="exportLoading"
@ -117,6 +119,7 @@
"
@share-request="shareRequest"
@select-request="selectRequest"
@sort-collections="sortCollections"
@select-response="selectResponse"
@select="selectPicked"
@update-request-order="updateRequestOrder"
@ -255,6 +258,7 @@ import {
deleteCollection,
duplicateTeamCollection,
moveRESTTeamCollection,
sortTeamCollections,
updateOrderRESTTeamCollection,
updateTeamCollection,
} from "~/helpers/backend/mutations/TeamCollection"
@ -277,7 +281,6 @@ import {
resolveSaveContextOnRequestReorder,
} from "~/helpers/collection/request"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
@ -301,6 +304,8 @@ import {
saveRESTRequestAs,
updateRESTCollectionOrder,
updateRESTRequestOrder,
sortRESTCollection,
sortRESTFolder,
} from "~/newstore/collections"
import { useLocalState } from "~/newstore/localstate"
@ -317,6 +322,9 @@ import { CollectionRunnerData } from "../http/test/RunnerModal.vue"
import { HoppCollectionVariable } from "@hoppscotch/data"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { CurrentValueService } from "~/services/current-environment-value.service"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { SortOptions } from "~/helpers/backend/graphql"
import { CurrentSortValuesService } from "~/services/current-sort.service"
const t = useI18n()
const toast = useToast()
@ -396,21 +404,18 @@ const requestMoveLoading = ref<string[]>([])
const secretEnvironmentService = useService(SecretEnvironmentService)
const currentEnvironmentValueService = useService(CurrentValueService)
// Sorting service to get and set sort options for collections and folders
const currentSortValuesService = useService(CurrentSortValuesService)
// TeamList-Adapter
const workspaceService = useService(WorkspaceService)
const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
// Team Collection Adapter
const teamCollectionAdapter = new TeamCollectionAdapter(null)
const teamCollectionList = useReadonlyStream(
teamCollectionAdapter.collections$,
[]
)
const teamLoadingCollections = useReadonlyStream(
teamCollectionAdapter.loadingCollections$,
[]
)
// Team Collection Service
const teamCollectionService = useService(TeamCollectionsService)
const teamCollections = teamCollectionService.collections
const teamEnvironmentAdapter = new TeamEnvironmentAdapter(undefined)
const {
@ -509,7 +514,6 @@ onMounted(async () => {
const switchToMyCollections = () => {
collectionsType.value.type = "my-collections"
collectionsType.value.selectedTeam = undefined
teamCollectionAdapter.changeTeamID(null)
}
/**
@ -534,13 +538,12 @@ const expandTeamCollection = (collectionID: string) => {
return
}
teamCollectionAdapter.expandCollection(collectionID)
teamCollectionService.expandCollection(collectionID)
}
const updateSelectedTeam = (team: TeamWorkspace) => {
if (team) {
collectionsType.value.type = "team-collections"
teamCollectionAdapter.changeTeamID(team.teamID)
collectionsType.value.selectedTeam = team
REMEMBERED_TEAM_ID.value = team.teamID
emit("update-team", team)
@ -881,6 +884,7 @@ const onAddRequest = async (requestName: string) => {
originLocation: "user-collection",
folderPath: path,
requestIndex: insertionIndex,
requestRefID: newRequest._ref_id,
},
inheritedProperties: cascadeParentCollectionForProperties(path, "rest"),
})
@ -933,9 +937,10 @@ const onAddRequest = async (requestName: string) => {
requestID: createRequestInCollection.id,
collectionID: path,
teamID: createRequestInCollection.collection.team.id,
requestRefID: newRequest._ref_id,
},
inheritedProperties:
teamCollectionAdapter.cascadeParentCollectionForProperties(path),
teamCollectionService.cascadeParentCollectionForProperties(path),
})
modalLoadingState.value = false
@ -1327,15 +1332,15 @@ const updateEditingResponse = (newName: string) => {
possibleExampleActiveTab.value.document.response.name = newName
nextTick(() => {
const doc = possibleExampleActiveTab.value.document
if (doc.type === "example-response") {
doc.isDirty = false
doc.saveContext = {
originLocation: "user-collection",
folderPath: folderPath,
requestIndex: requestIndex,
exampleID: editingResponseID.value!,
}
if (possibleExampleActiveTab.value.document.type === "test-runner")
return
possibleExampleActiveTab.value.document.isDirty = false
possibleExampleActiveTab.value.document.saveContext = {
originLocation: "user-collection",
folderPath: folderPath,
requestIndex: requestIndex,
exampleID: editingResponseID.value!,
}
})
}
@ -1396,14 +1401,13 @@ const updateEditingResponse = (newName: string) => {
) {
possibleActiveResponseTab.value.document.response.name = newName
nextTick(() => {
const doc = possibleActiveResponseTab.value.document
if (doc.type === "example-response") {
doc.isDirty = false
doc.saveContext = {
originLocation: "team-collection",
requestID,
exampleID: editingResponseID.value!,
}
if (possibleActiveResponseTab.value.document.type === "test-runner")
return
possibleActiveResponseTab.value.document.isDirty = false
possibleActiveResponseTab.value.document.saveContext = {
originLocation: "team-collection",
requestID,
exampleID: editingResponseID.value!,
}
})
}
@ -2051,7 +2055,7 @@ const selectRequest = (selectedRequest: {
cascadeParentCollectionForPropertiesForSearchResults(collectionID)
} else {
inheritedProperties =
teamCollectionAdapter.cascadeParentCollectionForProperties(folderPath)
teamCollectionService.cascadeParentCollectionForProperties(folderPath)
}
const possibleTab = tabs.getTabRefWithSaveContext({
@ -2071,6 +2075,7 @@ const selectRequest = (selectedRequest: {
requestID: requestIndex,
collectionID: folderPath,
exampleID: undefined,
requestRefID: request.id,
},
inheritedProperties: inheritedProperties,
})
@ -2093,6 +2098,7 @@ const selectRequest = (selectedRequest: {
originLocation: "user-collection",
folderPath: folderPath!,
requestIndex: parseInt(requestIndex),
requestRefID: request._ref_id ?? request.id,
},
inheritedProperties: cascadeParentCollectionForProperties(
folderPath,
@ -2169,7 +2175,7 @@ const selectResponse = (payload: {
exampleID: responseID,
},
inheritedProperties:
teamCollectionAdapter.cascadeParentCollectionForProperties(
teamCollectionService.cascadeParentCollectionForProperties(
folderPath
),
})
@ -2195,8 +2201,16 @@ const dropRequest = async (payload: {
folderPath?: string | undefined
requestIndex: string
destinationCollectionIndex: string
destinationParentPath?: string
requestRefID?: string
}) => {
const { folderPath, requestIndex, destinationCollectionIndex } = payload
const {
folderPath,
requestIndex,
destinationCollectionIndex,
destinationParentPath,
requestRefID,
} = payload
if (!requestIndex || !destinationCollectionIndex || !folderPath) return
@ -2209,6 +2223,7 @@ const dropRequest = async (payload: {
originLocation: "user-collection",
folderPath,
requestIndex: pathToLastIndex(requestIndex),
requestRefID,
})
// If there is a tab attached to this request, change save its save context
@ -2220,6 +2235,7 @@ const dropRequest = async (payload: {
myCollections.value,
destinationCollectionIndex
).length,
requestRefID: possibleTab.value.document.request._ref_id,
}
possibleTab.value.document.inheritedProperties =
@ -2271,10 +2287,11 @@ const dropRequest = async (payload: {
possibleTab.value.document.saveContext = {
originLocation: "team-collection",
requestID: requestIndex,
collectionID: destinationParentPath ?? destinationCollectionIndex,
}
possibleTab.value.document.inheritedProperties =
teamCollectionAdapter.cascadeParentCollectionForProperties(
destinationCollectionIndex
teamCollectionService.cascadeParentCollectionForProperties(
destinationParentPath ?? destinationCollectionIndex
)
}
toast.success(`${t("request.moved")}`)
@ -2407,16 +2424,7 @@ const dropCollection = async (payload: {
newCollectionPath
)
const inheritedProperty = cascadeParentCollectionForProperties(
newCollectionPath,
"rest"
)
updateInheritedPropertiesForAffectedRequests(
newCollectionPath,
inheritedProperty,
"rest"
)
updateInheritedPropertiesForAffectedRequests(newCollectionPath, "rest")
draggingToRoot.value = false
toast.success(`${t("collection.moved")}`)
@ -2444,22 +2452,16 @@ const dropCollection = async (payload: {
1
)
if (destinationParentPath && currentParentIndex) {
if (destinationParentPath) {
updateSaveContextForAffectedRequests(
currentParentIndex,
`${destinationParentPath}`
)
}
const inheritedProperty =
teamCollectionAdapter.cascadeParentCollectionForProperties(
currentParentIndex || collectionIndexDragged,
`${destinationParentPath}/${collectionIndexDragged}`
)
}
setTimeout(() => {
updateInheritedPropertiesForAffectedRequests(
`${destinationParentPath}/${collectionIndexDragged}`,
inheritedProperty,
"rest"
)
}, 300)
@ -2471,10 +2473,13 @@ const dropCollection = async (payload: {
/**
* Checks if the collection is already in the root
* @param id - path of the collection
* @param id - path of the collection, null if it's in the root
* @returns boolean - true if the collection is already in the root
*/
const isAlreadyInRoot = (id: string) => {
const isAlreadyInRoot = (id: string | null) => {
// If there is no id, it means the collection is in the root
if (!id) return true
const indexPath = pathToIndex(id)
return indexPath.length === 1
}
@ -2487,6 +2492,7 @@ const isAlreadyInRoot = (id: string) => {
const dropToRoot = async ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
const parentIndex = dataTransfer.getData("parentIndex")
if (!collectionIndexDragged) return
if (collectionsType.value.type === "my-collections") {
const isValidToken = await handleTokenValidation()
@ -2505,14 +2511,8 @@ const dropToRoot = async ({ dataTransfer }: DragEvent) => {
`${rootLength - 1}`
)
const inheritedProperty = cascadeParentCollectionForProperties(
`${rootLength - 1}`,
"rest"
)
updateInheritedPropertiesForAffectedRequests(
`${rootLength - 1}`,
inheritedProperty,
"rest"
)
}
@ -2539,6 +2539,16 @@ const dropToRoot = async ({ dataTransfer }: DragEvent) => {
collectionMoveLoading.value.indexOf(collectionIndexDragged),
1
)
if (collectionIndexDragged && parentIndex) {
updateSaveContextForAffectedRequests(parentIndex, null)
}
setTimeout(() => {
updateInheritedPropertiesForAffectedRequests(
`${collectionIndexDragged}`,
"rest"
)
}, 300)
toast.success(`${t("collection.moved")}`)
}
)
@ -2921,7 +2931,7 @@ const editProperties = async (payload: {
if (parentIndex) {
const { auth, headers, variables } =
teamCollectionAdapter.cascadeParentCollectionForProperties(parentIndex)
teamCollectionService.cascadeParentCollectionForProperties(parentIndex)
inheritedProperties = {
auth,
@ -3040,15 +3050,8 @@ const setCollectionProperties = (newCollection: {
editRESTFolder(path, collection)
}
const inheritedProperty = cascadeParentCollectionForProperties(path, "rest")
nextTick(() => {
updateInheritedPropertiesForAffectedRequests(
path,
inheritedProperty,
"rest",
collection._ref_id ?? collectionId!
)
updateInheritedPropertiesForAffectedRequests(path, "rest")
})
toast.success(t("collection.properties_updated"))
} else if (hasTeamWriteAccess.value && collectionId) {
@ -3072,14 +3075,7 @@ const setCollectionProperties = (newCollection: {
//This is a hack to update the inherited properties of the requests if there an tab opened
// since it takes a little bit of time to update the collection tree
setTimeout(() => {
const inheritedProperty =
teamCollectionAdapter.cascadeParentCollectionForProperties(path)
updateInheritedPropertiesForAffectedRequests(
path,
inheritedProperty,
"rest",
collectionId
)
updateInheritedPropertiesForAffectedRequests(path, "rest")
}, 300)
}
@ -3093,7 +3089,7 @@ const runCollectionHandler = (
) => {
if (payload.path && collectionsType.value.type === "team-collections") {
const inheritedProperties =
teamCollectionAdapter.cascadeParentCollectionForProperties(payload.path)
teamCollectionService.cascadeParentCollectionForProperties(payload.path)
if (inheritedProperties) {
collectionRunnerData.value = {
@ -3111,6 +3107,51 @@ const runCollectionHandler = (
showCollectionsRunnerModal.value = true
}
const sortCollections = (payload: {
collectionID: string | null
sortOrder: "asc" | "desc"
collectionRefID: string
}) => {
const { collectionID, sortOrder, collectionRefID } = payload
if (collectionsType.value.type === "my-collections") {
const collectionIndex = collectionID ? parseInt(collectionID) : null
if (isAlreadyInRoot(collectionID)) {
sortRESTCollection(collectionIndex, sortOrder)
toast.success(t("collection.sorted"))
} else {
if (!collectionID) return
sortRESTFolder(collectionID, sortOrder)
toast.success(t("folder.sorted"))
}
} else if (hasTeamWriteAccess.value) {
pipe(
sortTeamCollections(
collectionsType.value.selectedTeam.teamID,
collectionID,
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(t("collection.sorted"))
}
)
)()
}
// Set the sort option in the service to persist the sort option
// when the user navigates away and comes back
currentSortValuesService.setSortOption(collectionRefID, {
sortBy: "name",
sortOrder,
})
}
const resolveConfirmModal = (title: string | null) => {
if (title === `${t("confirm.remove_collection")}`) onRemoveCollection()
else if (title === `${t("confirm.remove_request")}`) onRemoveRequest()

View file

@ -128,7 +128,6 @@ import { useService } from "dioc/vue"
import { pipe } from "fp-ts/lib/function"
import * as TE from "fp-ts/TaskEither"
import { computed, nextTick, onMounted, ref } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { useColorMode } from "~/composables/theming"
import { useToast } from "~/composables/toast"
import { GQLError } from "~/helpers/backend/GQLClient"
@ -142,7 +141,6 @@ import {
TestRunnerCollectionsAdapter,
} from "~/helpers/runner/adapter"
import { getErrorMessage } from "~/helpers/runner/collection-tree"
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
import { transformInheritedCollectionVariablesToAggregateEnv } from "~/helpers/utils/inheritedCollectionVarTransformer"
import {
getRESTCollectionByRefId,
@ -151,6 +149,7 @@ import {
} from "~/newstore/collections"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest"
import { TeamCollectionsService } from "~/services/team-collection.service"
import {
TestRunnerRequest,
TestRunnerService,
@ -161,11 +160,8 @@ const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
const teamCollectionAdapter = new TeamCollectionAdapter(null)
const teamCollectionList = useReadonlyStream(
teamCollectionAdapter.collections$,
[]
)
const teamCollectionService = useService(TeamCollectionsService)
const teamCollectionList = teamCollectionService.collections
const props = defineProps<{ modelValue: HoppTab<HoppTestRunnerDocument> }>()

View file

@ -0,0 +1,11 @@
mutation SortTeamCollections(
$teamID: ID!
$parentCollectionID: ID
$sortOption: SortOptions!
) {
sortTeamCollections(
teamID: $teamID
parentCollectionID: $parentCollectionID
sortOption: $sortOption
)
}

View file

@ -0,0 +1,3 @@
subscription TeamChildCollectionSorted($teamID: ID!) {
teamChildCollectionsSorted(teamID: $teamID)
}

View file

@ -0,0 +1,3 @@
subscription TeamRootCollectionsSorted($teamID: ID!) {
teamRootCollectionsSorted(teamID: $teamID)
}

View file

@ -21,6 +21,10 @@ import {
RenameCollectionDocument,
RenameCollectionMutation,
RenameCollectionMutationVariables,
SortOptions,
SortTeamCollectionsDocument,
SortTeamCollectionsMutation,
SortTeamCollectionsMutationVariables,
UpdateCollectionOrderDocument,
UpdateCollectionOrderMutation,
UpdateCollectionOrderMutationVariables,
@ -152,3 +156,18 @@ export const duplicateTeamCollection = (collectionID: string) =>
>(DuplicateTeamCollectionDocument, {
collectionID,
})
export const sortTeamCollections = (
teamID: string,
parentCollectionID: string | null,
sortOption: SortOptions
) =>
runMutation<
SortTeamCollectionsMutation,
SortTeamCollectionsMutationVariables,
""
>(SortTeamCollectionsDocument, {
teamID,
parentCollectionID,
sortOption,
})

View file

@ -5,19 +5,13 @@ import { runGQLQuery } from "../backend/GQLClient"
import * as E from "fp-ts/Either"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { GQLTabService } from "~/services/tab/graphql"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { cascadeParentCollectionForProperties } from "~/newstore/collections"
/**
* Resolve save context on reorder
* @param payload
* @param payload.lastIndex
* @param payload.newIndex
* @param folderPath
* @param payload.length
* @returns
*/
export function resolveSaveContextOnCollectionReorder(
payload: {
lastIndex: number
@ -62,6 +56,7 @@ export function resolveSaveContextOnCollectionReorder(
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
if (tab.document.type === "test-runner") return false
return (
tab.document.saveContext?.originLocation === "user-collection" &&
affectedPaths.has(tab.document.saveContext.folderPath)
@ -69,36 +64,54 @@ export function resolveSaveContextOnCollectionReorder(
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
if (
tab.value.document.type !== "test-runner" &&
tab.value.document.saveContext?.originLocation === "user-collection"
) {
const newPath = affectedPaths.get(
tab.value.document.saveContext?.folderPath
tab.value.document.saveContext.folderPath
)!
tab.value.document.saveContext.folderPath = newPath
}
}
}
/**
* Helper to transform team collection IDs when folders move and trim leading slashes.
* @param currentID Current collection ID
* @param oldPath Old collection path
* @param newPath New collection path
* @returns Updated collection ID
*/
const updateCollectionIDPath = (
currentID: string | undefined,
oldPath: string,
newPath: string | null
): string | undefined => {
if (!currentID) return currentID
const replaced = currentID.replace(oldPath, newPath ?? "")
return replaced.replace(/^\/+/, "")
}
/**
* Returns the last folder path from the given path.
* @param path Path can be folder path or collection path
* * @param path Path can be folder path or collection path
* @returns Get the last folder path from the given path
*/
const getLastParentFolderPath = (path?: string) => {
if (!path) return ""
const pathArray = path.split("/")
return pathArray.slice(pathArray.length - 1, pathArray.length).join("/")
return pathArray[pathArray.length - 1] ?? ""
}
/**
* Resolve save context for affected requests on drop folder from one to another
* @param oldFolderPath
* @param newFolderPath
* @returns
* Resolve save context for affected requests on drop folder
* @param oldFolderPath Old folder path
* @param newFolderPath New folder path
*/
export function updateSaveContextForAffectedRequests(
oldFolderPath: string,
newFolderPath: string
newFolderPath: string | null
) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
@ -114,7 +127,11 @@ export function updateSaveContextForAffectedRequests(
for (const tab of tabs) {
if (tab.value.document.type === "test-runner") return
if (tab.value.document.saveContext?.originLocation === "user-collection") {
if (
tab.value.document.saveContext?.originLocation === "user-collection" &&
newFolderPath
) {
tab.value.document.saveContext = {
...tab.value.document.saveContext,
folderPath: tab.value.document.saveContext.folderPath.replace(
@ -127,7 +144,8 @@ export function updateSaveContextForAffectedRequests(
) {
tab.value.document.saveContext = {
...tab.value.document.saveContext,
collectionID: tab.value.document.saveContext!.collectionID?.replace(
collectionID: updateCollectionIDPath(
tab.value.document.saveContext.collectionID,
oldFolderPath,
newFolderPath
),
@ -135,97 +153,14 @@ export function updateSaveContextForAffectedRequests(
}
}
}
/**
* Used to check the new folder path is close to the save context folder path or not
* @param folderPathCurrent The path saved as the inherited path in the inherited properties
* @param newFolderPath The incoming path
* @param saveContextPath The save context of the request
* @returns The path which is close to saveContext.folderPath
*/
function folderPathCloseToSaveContext(
folderPathCurrent: string | undefined,
newFolderPath: string,
saveContextPath: string
) {
if (!folderPathCurrent) return newFolderPath
const folderPathCurrentArray = folderPathCurrent.split("/")
const newFolderPathArray = newFolderPath.split("/")
const saveContextFolderPathArray = saveContextPath.split("/")
const folderPathCurrentMatch = folderPathCurrentArray.filter(
(folder, i) => folder === saveContextFolderPathArray[i]
).length
const newFolderPathMatch = newFolderPathArray.filter(
(folder, i) => folder === saveContextFolderPathArray[i]
).length
return folderPathCurrentMatch > newFolderPathMatch
? folderPathCurrent
: newFolderPath
}
function removeDuplicatesAndKeepLast(arr: HoppInheritedProperty["headers"]) {
const keyMap: { [key: string]: number[] } = {} // Map to store array of indices for each key
// Populate keyMap with the indices of each key
arr.forEach((item, index) => {
const key = item.inheritedHeader.key
if (!(key in keyMap)) {
keyMap[key] = []
}
keyMap[key].push(index)
})
// Create a new array containing only the last occurrence of each key
const result = []
for (const key in keyMap) {
if (Object.prototype.hasOwnProperty.call(keyMap, key)) {
const lastIndex = keyMap[key][keyMap[key].length - 1]
result.push(arr[lastIndex])
}
}
// Sort the result array based on the parentID
result.sort((a, b) => a.parentID.localeCompare(b.parentID))
return result
}
/**
* Order collection variables based on their parentPath and parentID
* eg: path like 4/0/0 should come before 4/0/1 nad 4 should come before 4/0
* @param vars Collection of variables to be ordered
* @returns Ordered collection of variables
*/
const orderCollectionVariables = (
vars: HoppInheritedProperty["variables"]
): HoppInheritedProperty["variables"] => {
return vars.sort((a, b) => {
if (a.parentPath && b.parentPath) {
return a.parentPath.localeCompare(b.parentPath)
}
if (a.parentPath) {
return -1
}
if (b.parentPath) {
return 1
}
return a.parentID.localeCompare(b.parentID)
})
}
export function updateInheritedPropertiesForAffectedRequests(
path: string,
inheritedProperties: HoppInheritedProperty,
type: "rest" | "graphql",
collectionId?: string
type: "rest" | "graphql"
) {
const tabService =
type === "rest" ? getService(RESTTabService) : getService(GQLTabService)
const teamCollectionService = getService(TeamCollectionsService)
const effectedTabs = tabService.getTabsRefTo((tab) => {
if ("type" in tab.document && tab.document.type === "test-runner")
@ -245,71 +180,33 @@ export function updateInheritedPropertiesForAffectedRequests(
)
})
effectedTabs.map((tab) => {
effectedTabs.forEach((tab) => {
if (
"type" in tab.value.document &&
tab.value.document.type === "test-runner"
)
return
if (!("inheritedProperties" in tab.value.document)) return
const inheritedParentID =
tab.value.document.inheritedProperties?.auth.parentID
const contextPath =
tab.value.document.saveContext?.originLocation === "team-collection"
? tab.value.document.saveContext.collectionID
: tab.value.document.saveContext?.folderPath
const effectedPath = folderPathCloseToSaveContext(
inheritedParentID,
path,
contextPath ?? ""
)
if (effectedPath === path) {
if (tab.value.document.inheritedProperties) {
tab.value.document.inheritedProperties.auth = inheritedProperties.auth
}
}
if (tab.value.document.inheritedProperties?.headers) {
// filter out the headers with the parentID not as the path
const headers = tab.value.document.inheritedProperties.headers.filter(
(header) => header.parentID !== path
)
// filter out the headers with the parentID as the path in the inheritedProperties
const inheritedHeaders = inheritedProperties.headers.filter(
(header) =>
path.startsWith(header.parentID ?? "") || header.parentID === path
)
// merge the headers with the parentID as the path
const mergedHeaders = removeDuplicatesAndKeepLast([
...new Set([...inheritedHeaders, ...headers]),
])
tab.value.document.inheritedProperties.headers = mergedHeaders
}
if (tab.value.document.inheritedProperties?.variables && !collectionId) {
tab.value.document.inheritedProperties.variables =
inheritedProperties.variables
} else if (
tab.value.document.inheritedProperties?.variables &&
collectionId
if (
tab.value.document.saveContext?.originLocation === "team-collection" &&
tab.value.document.inheritedProperties
) {
const tabInheritedVariables =
tab.value.document.inheritedProperties.variables.filter(
(variable) => variable.parentID !== collectionId
tab.value.document.inheritedProperties =
teamCollectionService.cascadeParentCollectionForProperties(
tab.value.document.saveContext.collectionID!
)
}
// filter out the variables with the parentID as the path in the inheritedProperties
const inheritedVariables = inheritedProperties.variables.filter(
(variable) => variable.parentID === collectionId
)
const finalVariables = orderCollectionVariables([
...new Set([...inheritedVariables, ...tabInheritedVariables]),
])
tab.value.document.inheritedProperties.variables = finalVariables
if (
tab.value.document.saveContext?.originLocation === "user-collection" &&
tab.value.document.inheritedProperties
) {
tab.value.document.inheritedProperties =
cascadeParentCollectionForProperties(
tab.value.document.saveContext.folderPath,
type
)
}
})
}
@ -317,6 +214,7 @@ export function updateInheritedPropertiesForAffectedRequests(
function resetSaveContextForAffectedRequests(folderPath: string) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
if (tab.document.type === "test-runner") return false
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(folderPath)
@ -324,6 +222,7 @@ function resetSaveContextForAffectedRequests(folderPath: string) {
})
for (const tab of tabs) {
if (tab.value.document.type === "test-runner") return
tab.value.document.saveContext = null
tab.value.document.isDirty = true
@ -331,8 +230,6 @@ function resetSaveContextForAffectedRequests(folderPath: string) {
// since the request is deleted, we need to remove the saved responses as well
tab.value.document.request.responses = {}
}
//
}
}
@ -340,20 +237,19 @@ function resetSaveContextForAffectedRequests(folderPath: string) {
* Reset save context to null if requests are deleted from the team collection or its folder
* only runs when collection or folder is deleted
*/
export async function resetTeamRequestsContext() {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
if (tab.document.type === "test-runner") return false
return tab.document.saveContext?.originLocation === "team-collection"
})
for (const tab of tabs) {
if (tab.value.document.type === "test-runner") return
if (tab.value.document.saveContext?.originLocation === "team-collection") {
const data = await runGQLQuery({
query: GetSingleRequestDocument,
variables: {
requestID: tab.value.document.saveContext?.requestID,
},
variables: { requestID: tab.value.document.saveContext.requestID },
})
if (E.isRight(data) && data.right.request === null) {
@ -377,12 +273,12 @@ export function getFoldersByPath(
// path will be like this "0/0/1" these are the indexes of the folders
const pathArray = path.split("/").map((index) => parseInt(index))
let currentCollection = collections[pathArray[0]]
if (pathArray.length === 1) {
return currentCollection.folders
}
for (let i = 1; i < pathArray.length; i++) {
const folder = currentCollection.folders[pathArray[i]]
if (folder) currentCollection = folder

View file

@ -1033,7 +1033,27 @@ data2: {"type":"test2","typeId":"123"}`,
describe("Parse curl command to Hopp REST Request", () => {
for (const [i, { command, response }] of samples.entries()) {
test(`for sample #${i + 1}:\n\n${command}`, () => {
expect(parseCurlToHoppRESTReq(command)).toEqual(response)
const actual = parseCurlToHoppRESTReq(command)
/**
* An object possibly carrying an internal reference id.
* @typedef {object} RefIdCarrier
* @property {unknown} [_ref_id]
*/
/**
* @template {object} T
* @param {T & RefIdCarrier} obj
* @returns {Omit<T, "_ref_id">}
*/
const stripRefId = (obj) => {
const clone = { ...obj }
delete clone._ref_id
return clone
}
// Strip off _ref_id added by makeRESTRequest for equality check because it is generated randomly
expect(stripRefId(actual)).toEqual(stripRefId(response))
})
}
})

View file

@ -22,11 +22,15 @@ export type HoppRESTSaveContext =
/**
* Index to the request
*/
requestIndex: number
requestIndex?: number
/**
* ID of the example response
*/
exampleID?: string
/**
* Reference ID of the request, if available
*/
requestRefID?: string
}
| {
/**
@ -49,6 +53,10 @@ export type HoppRESTSaveContext =
* ID of the example response
*/
exampleID?: string
/**
* Reference ID of the request, if available
*/
requestRefID?: string
}
| null

View file

@ -24,6 +24,10 @@ export type HoppRequestSaveContext =
* Current request
*/
req?: HoppRESTRequest
/**
* Reference ID of the request, if available
*/
requestRefID?: string
}
| {
/**
@ -46,4 +50,8 @@ export type HoppRequestSaveContext =
* Current request
*/
req?: HoppRESTRequest
/**
* Reference ID of the request, if available
*/
requestRefID?: string
}

View file

@ -235,6 +235,25 @@ function reorderItems(array: unknown[], from: number, to: number) {
}
}
function createComparator<T>(
key: keyof T,
sortOrder: "asc" | "desc" = "asc"
): (a: T, b: T) => number {
return (a, b) => {
const aVal = a[key]
const bVal = b[key]
if (typeof aVal === "string" && typeof bVal === "string") {
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
}
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1
return 0
}
}
const restCollectionDispatchers = defineDispatchers({
setCollections(
_: RESTCollectionStoreType,
@ -298,6 +317,37 @@ const restCollectionDispatchers = defineDispatchers({
}
},
sortRESTCollection(
{ state }: RESTCollectionStoreType,
{
collectionPath,
sortOrder,
}: { collectionPath: number | null; sortOrder: "asc" | "desc" }
) {
const newState = state
// If collectionPath is null, we are sorting the root collections
if (collectionPath === null || isNaN(collectionPath)) {
return {
state: newState.sort(createComparator("name", sortOrder)),
}
}
const collection = newState.find((_, index) => index === collectionPath)
if (!collection) {
console.error(`Collection not found.`)
return {}
}
collection.requests.sort(createComparator("name", sortOrder))
collection.folders.sort(createComparator("name", sortOrder))
return {
state: newState,
}
},
addFolder(
{ state }: RESTCollectionStoreType,
{ name, path }: { name: string; path: string }
@ -477,6 +527,40 @@ const restCollectionDispatchers = defineDispatchers({
return { state: newState }
},
sortRESTFolder(
{ state }: RESTCollectionStoreType,
{
path,
sortOrder,
}: {
path: string
sortOrder: "asc" | "desc"
}
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log("Given path too short. Skipping request.")
return {}
}
const target = navigateToFolderWithIndexPath(newState, indexPaths)
if (target === null) {
console.log(
`Could not resolve path '${path}'. Ignoring sortRESTFolder dispatch.`
)
return {}
}
target.requests.sort(createComparator("name", sortOrder))
target.folders.sort(createComparator("name", sortOrder))
return {
state: newState,
}
},
updateCollectionOrder(
{ state }: RESTCollectionStoreType,
{
@ -1396,6 +1480,19 @@ export function editRESTCollection(
})
}
export function sortRESTCollection(
collectionPath: number | null,
sortOrder: "asc" | "desc"
) {
restCollectionStore.dispatch({
dispatcher: "sortRESTCollection",
payload: {
collectionPath,
sortOrder,
},
})
}
export function addRESTFolder(name: string, path: string) {
restCollectionStore.dispatch({
dispatcher: "addFolder",
@ -1436,6 +1533,16 @@ export function moveRESTFolder(path: string, destinationPath: string | null) {
})
}
export function sortRESTFolder(path: string, sortOrder: "asc" | "desc") {
restCollectionStore.dispatch({
dispatcher: "sortRESTFolder",
payload: {
path,
sortOrder,
},
})
}
export function duplicateRESTCollection(
path: string,
collectionSyncID?: string

View file

@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from "vitest"
import { TestContainer } from "dioc/testing"
import {
CurrentSortOption,
CurrentSortValuesService,
} from "../current-sort.service"
describe("CurrentSortValuesService", () => {
let container: TestContainer
let service: CurrentSortValuesService
beforeEach(() => {
container = new TestContainer()
service = container.bind(CurrentSortValuesService)
})
describe("setSortOption & getSortOption", () => {
it("should set and retrieve a sort option for a given ID", () => {
const id = "col1"
const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" }
service.setSortOption(id, option)
expect(service.getSortOption(id)).toEqual(option)
})
it("should return undefined for a non-existent ID", () => {
expect(service.getSortOption("missing")).toBeUndefined()
})
})
describe("removeSortOption", () => {
it("should remove a sort option for a given ID", () => {
const id = "col1"
const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" }
service.setSortOption(id, option)
service.removeSortOption(id)
expect(service.getSortOption(id)).toBeUndefined()
})
})
describe("clearAllSortOptions", () => {
it("should clear all sort options", () => {
service.setSortOption("col1", { sortBy: "name", sortOrder: "asc" })
service.setSortOption("col2", { sortBy: "name", sortOrder: "desc" })
service.clearAllSortOptions()
expect(service.currentSortOptions.size).toBe(0)
})
})
describe("loadCurrentSortValuesFromPersistedState", () => {
it("should load sort options from persisted state", () => {
const state = {
col1: { sortBy: "name", sortOrder: "asc" } as CurrentSortOption,
col2: { sortBy: "name", sortOrder: "desc" } as CurrentSortOption,
}
service.loadCurrentSortValuesFromPersistedState(state)
expect(service.getSortOption("col1")).toEqual(state.col1)
expect(service.getSortOption("col2")).toEqual(state.col2)
})
it("should clear existing options before loading", () => {
service.setSortOption("old", { sortBy: "name", sortOrder: "asc" })
const state = {
new: { sortBy: "name", sortOrder: "desc" } as CurrentSortOption,
}
service.loadCurrentSortValuesFromPersistedState(state)
expect(service.getSortOption("old")).toBeUndefined()
expect(service.getSortOption("new")).toEqual(state.new)
})
})
describe("persistableCurrentSortValues", () => {
it("should return a persistable object of current sort options", () => {
const id = "col1"
const option: CurrentSortOption = { sortBy: "name", sortOrder: "asc" }
service.setSortOption(id, option)
expect(service.persistableCurrentSortValues.value).toEqual({
[id]: option,
})
})
it("should return an empty object when no sort options exist", () => {
expect(service.persistableCurrentSortValues.value).toEqual({})
})
})
})

View file

@ -64,6 +64,7 @@ describe("WorkspaceService", () => {
type: "team",
teamID: "test",
teamName: "before update",
role: null,
})
service.updateWorkspaceTeamName("test")
@ -72,6 +73,7 @@ describe("WorkspaceService", () => {
type: "team",
teamID: "test",
teamName: "test",
role: null,
})
})
@ -100,12 +102,14 @@ describe("WorkspaceService", () => {
type: "team",
teamID: "test",
teamName: "test",
role: null,
})
expect(service.currentWorkspace.value).toEqual({
type: "team",
teamID: "test",
teamName: "test",
role: null,
})
})
})

View file

@ -0,0 +1,89 @@
import { Service } from "dioc"
import { cloneDeep } from "lodash-es"
import { computed, reactive } from "vue"
/**
* Defines a sort option.
* For now, we only support sorting by name, ascending or descending.
* In the future, we can add more sort options like date created, date modified, etc.
*/
export type CurrentSortOption = {
sortBy: "name"
sortOrder: "asc" | "desc"
}
/**
* This service is used to store and manage current sort options of collections and folders.
* This can be order by name, ascending, descending, etc.
*/
export class CurrentSortValuesService extends Service {
public static readonly ID = "CURRENT_SORT_VALUES_SERVICE"
/**
* Map of sort options for collections and folders.
* Key is the ID of the collection or folder.
* Value is the sort option.
*/
public currentSortOptions = reactive(new Map<string, CurrentSortOption>())
/**
* Gets the current sort option for a given collection or folder ID.
* @param id ID of the collection or folder.
* @returns Current sort option for the given ID, or `undefined` if not found.
*/
public getSortOption(id: string): CurrentSortOption | undefined {
return this.currentSortOptions.get(id)
}
/**
* Sets the current sort option for a given collection or folder ID.
* @param id ID of the collection or folder.
* @param sortOption Sort option to set.
*/
public setSortOption(id: string, sortOption: CurrentSortOption) {
this.currentSortOptions.set(id, cloneDeep(sortOption))
}
/**
* Removes the current sort option for a given collection or folder ID.
* @param id ID of the collection or folder.
*/
public removeSortOption(id: string) {
this.currentSortOptions.delete(id)
}
/**
* Clears all sort options.
* This is useful when the user logs out or switches accounts.
* */
public clearAllSortOptions() {
this.currentSortOptions.clear()
}
/**
* Loads current sort values from persisted state.
* @param currentSortOptions Object containing current sort options to load.
*/
public loadCurrentSortValuesFromPersistedState(
currentSortOptions: Record<string, CurrentSortOption>
) {
if (currentSortOptions) {
this.clearAllSortOptions()
Object.entries(currentSortOptions).forEach(([id, sortOption]) => {
this.setSortOption(id, sortOption)
})
}
}
/**
* Returns current sort options in a format suitable for persistence.
*/
public persistableCurrentSortValues = computed(() => {
const currentSortOptions: Record<string, CurrentSortOption> = {}
this.currentSortOptions.forEach((option, id) => {
currentSortOptions[id] = option
})
return currentSortOptions
})
}

View file

@ -240,6 +240,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRequestDocument> = {
body: { contentType: null, body: null },
requestVariables: [],
responses: {},
_ref_id: "req_ref_id",
},
isDirty: false,
type: "request",

View file

@ -70,6 +70,7 @@ import { WSRequest$, setWSRequest } from "../../newstore/WebSocketSession"
import {
CURRENT_ENVIRONMENT_VALUE_SCHEMA,
CURRENT_SORT_VALUES_SCHEMA,
ENVIRONMENTS_SCHEMA,
GLOBAL_ENVIRONMENT_SCHEMA,
GQL_COLLECTION_SCHEMA,
@ -100,6 +101,10 @@ import {
import { cloneDeep } from "lodash-es"
import { fixBrokenRequestVersion } from "~/helpers/fixBrokenRequestVersion"
import { fixBrokenEnvironmentVersion } from "~/helpers/fixBrokenEnvironmentVersion"
import {
CurrentSortOption,
CurrentSortValuesService,
} from "../current-sort.service"
export const STORE_NAMESPACE = "persistence.v1"
@ -122,6 +127,7 @@ export const STORE_KEYS = {
GQL_TABS: "gqlTabs",
SECRET_ENVIRONMENTS: "secretEnvironments",
CURRENT_ENVIRONMENT_VALUE: "currentEnvironmentValue",
CURRENT_SORT_VALUES: "currentSortValues",
SCHEMA_VERSION: "schema_version",
} as const
@ -187,6 +193,10 @@ export class PersistenceService extends Service {
private readonly currentEnvironmentValueService =
this.bind(CurrentValueService)
private readonly currentSortValuesService = this.bind(
CurrentSortValuesService
)
private showErrorToast(key: string) {
const toast = useToast()
toast.error(
@ -698,6 +708,50 @@ export class PersistenceService extends Service {
})
}
private async setupCurrentSortValuesPersistence() {
const loadResult = await Store.get<any>(
STORE_NAMESPACE,
STORE_KEYS.CURRENT_SORT_VALUES
)
try {
if (E.isRight(loadResult) && loadResult.right) {
const result = CURRENT_SORT_VALUES_SCHEMA.safeParse(loadResult.right)
if (result.success) {
this.currentSortValuesService.loadCurrentSortValuesFromPersistedState(
result.data
)
} else {
this.showErrorToast(STORE_KEYS.CURRENT_SORT_VALUES)
await Store.set(
STORE_NAMESPACE,
`${STORE_KEYS.CURRENT_SORT_VALUES}-backup`,
loadResult.right
)
console.error(
`Failed parsing persisted CURRENT_SORT_VALUES:`,
JSON.stringify(loadResult.right)
)
}
}
} catch (e) {
console.error(`Failed parsing persisted CURRENT_SORT_VALUES:`, loadResult)
}
watchDebounced(
this.currentSortValuesService.persistableCurrentSortValues,
async (newData: Record<string, CurrentSortOption>) => {
await Store.set(
STORE_NAMESPACE,
STORE_KEYS.CURRENT_SORT_VALUES,
newData
)
},
{ debounce: 500 }
)
}
private async setupWebsocketPersistence() {
const loadResult = await Store.get<any>(
STORE_NAMESPACE,
@ -986,6 +1040,8 @@ export class PersistenceService extends Service {
this.setupSecretEnvironmentsPersistence(),
this.setupCurrentEnvironmentValuePersistence(),
this.setupCurrentSortValuesPersistence(),
])
}

View file

@ -404,6 +404,18 @@ export const CURRENT_ENVIRONMENT_VALUE_SCHEMA = z.union([
),
])
export const CURRENT_SORT_VALUES_SCHEMA = z.union([
z.object({}).strict(),
z.record(
z.string(),
z.object({
sortBy: z.enum(["name"]),
sortOrder: z.enum(["asc", "desc"]),
})
),
])
const HoppTestResultSchema = z
.object({
tests: z.array(HoppTestDataSchema),
@ -511,8 +523,9 @@ const HoppRESTSaveContextSchema = z.nullable(
.object({
originLocation: z.literal("user-collection"),
folderPath: z.string(),
requestIndex: z.number(),
requestIndex: z.optional(z.number()),
exampleID: z.optional(z.string()),
requestRefID: z.optional(z.string()),
})
.strict(),
z
@ -522,6 +535,7 @@ const HoppRESTSaveContextSchema = z.nullable(
teamID: z.optional(z.string()),
collectionID: z.optional(z.string()),
exampleID: z.optional(z.string()),
requestRefID: z.optional(z.string()),
})
.strict(),
])

View file

@ -1,5 +1,4 @@
import { Container } from "dioc"
import { isEqual } from "lodash-es"
import { computed } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTSaveContext, HoppTabDocument } from "~/helpers/rest/document"
@ -84,7 +83,13 @@ export class RESTTabService extends TabService<HoppTabDocument> {
) {
return this.getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) {
} else if (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath === ctx?.folderPath &&
tab.document.saveContext.requestIndex === ctx?.requestIndex &&
tab.document.saveContext.exampleID === ctx?.exampleID &&
tab.document.saveContext.requestRefID === ctx?.requestRefID
) {
return this.getTabRef(tab.id)
}
}

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,9 @@ import V13_VERSION from "./v/13"
import { HoppRESTAuth } from "./v/15/auth"
import V14_VERSION from "./v/14"
import V15_VERSION from "./v/15/index"
import V16_VERSION from "./v/16"
import { HoppRESTRequestResponses } from "../rest-request-response"
import { generateUniqueRefId } from "../utils/collection"
export * from "./content-types"
@ -73,7 +75,7 @@ const versionedObject = z.object({
})
export const HoppRESTRequest = createVersionedEntity({
latestVersion: 15,
latestVersion: 16,
versionMap: {
0: V0_VERSION,
1: V1_VERSION,
@ -91,6 +93,7 @@ export const HoppRESTRequest = createVersionedEntity({
13: V13_VERSION,
14: V14_VERSION,
15: V15_VERSION,
16: V16_VERSION,
},
getVersion(data) {
// For V1 onwards we have the v string storing the number
@ -131,9 +134,10 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
lodashIsEqualEq
),
responses: lodashIsEqualEq,
_ref_id: undefinedEq(S.Eq),
})
export const RESTReqSchemaVersion = "15"
export const RESTReqSchemaVersion = "16"
export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
@ -160,6 +164,8 @@ export function safelyExtractRESTRequest(
if (!!x && typeof x === "object") {
if ("id" in x && typeof x.id === "string") req.id = x.id
if ("_ref_id" in x && typeof x._ref_id === "string") req._ref_id = x._ref_id
if ("name" in x && typeof x.name === "string") req.name = x.name
if ("method" in x && typeof x.method === "string") req.method = x.method
@ -185,7 +191,6 @@ export function safelyExtractRESTRequest(
const result = HoppRESTAuth.safeParse(x.auth)
if (result.success) {
// @ts-ignore
req.auth = result.data
}
}
@ -230,6 +235,7 @@ export function makeRESTRequest(
): HoppRESTRequest {
return {
v: RESTReqSchemaVersion,
_ref_id: x._ref_id ?? generateUniqueRefId("req"),
...x,
}
}
@ -254,6 +260,7 @@ export function getDefaultRESTRequest(): HoppRESTRequest {
},
requestVariables: [],
responses: {},
_ref_id: generateUniqueRefId("req"),
}
}

View file

@ -0,0 +1,23 @@
import { V15_SCHEMA } from "./15"
import { z } from "zod"
import { defineVersion } from "verzod"
import { generateUniqueRefId } from "../../utils/collection"
export const V16_SCHEMA = V15_SCHEMA.extend({
v: z.literal("16"),
_ref_id: z.string().optional(),
})
const V16_VERSION = defineVersion({
schema: V16_SCHEMA,
initial: false,
up(old: z.infer<typeof V16_SCHEMA>) {
return {
...old,
v: "16" as const,
_ref_id: old._ref_id ?? generateUniqueRefId("req"),
}
},
})
export default V16_VERSION

View file

@ -0,0 +1,3 @@
mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) {
duplicateUserCollection(collectionID: $collectionID, reqType: $reqType)
}

View file

@ -0,0 +1,9 @@
mutation SortUserCollections(
$parentCollectionID: ID
$sortOption: SortOptions!
) {
sortUserCollections(
parentCollectionID: $parentCollectionID
sortOption: $sortOption
)
}

View file

@ -0,0 +1,6 @@
subscription UserChildCollectionSorted {
userChildCollectionsSorted {
parentCollectionID
sortOption
}
}

View file

@ -0,0 +1,6 @@
subscription UserRootCollectionsSorted {
userRootCollectionsSorted {
parentCollectionID
sortOption
}
}

View file

@ -28,6 +28,9 @@ import {
DeleteUserRequestDocument,
DeleteUserRequestMutation,
DeleteUserRequestMutationVariables,
DuplicateUserCollectionDocument,
DuplicateUserCollectionMutation,
DuplicateUserCollectionMutationVariables,
ExportUserCollectionsToJsonDocument,
ExportUserCollectionsToJsonQuery,
ExportUserCollectionsToJsonQueryVariables,
@ -50,6 +53,10 @@ import {
RenameUserCollectionMutation,
RenameUserCollectionMutationVariables,
ReqType,
SortOptions,
SortUserCollectionsDocument,
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
UpdateGqlUserRequestDocument,
UpdateGqlUserRequestMutation,
UpdateGqlUserRequestMutationVariables,
@ -62,6 +69,7 @@ import {
UpdateUserCollectionOrderDocument,
UpdateUserCollectionOrderMutation,
UpdateUserCollectionOrderMutationVariables,
UserChildCollectionSortedDocument,
UserCollectionCreatedDocument,
UserCollectionDuplicatedDocument,
UserCollectionMovedDocument,
@ -72,6 +80,7 @@ import {
UserRequestDeletedDocument,
UserRequestMovedDocument,
UserRequestUpdatedDocument,
UserRootCollectionsSortedDocument,
} from "../../api/generated/graphql"
export const createRESTRootUserCollection = (title: string, data?: string) =>
@ -197,6 +206,32 @@ export const moveUserCollection = (
destCollectionID: destinationCollectionID,
})()
export const duplicateUserCollection = (
collectionID: string,
reqType: ReqType
) =>
runMutation<
DuplicateUserCollectionMutation,
DuplicateUserCollectionMutationVariables,
""
>(DuplicateUserCollectionDocument, {
collectionID,
reqType,
})()
export const sortUserCollections = (
parentCollectionID: string | null,
sortOption: SortOptions
) =>
runMutation<
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
""
>(SortUserCollectionsDocument, {
parentCollectionID,
sortOption,
})()
export const editUserRequest = (
requestID: string,
title: string,
@ -337,6 +372,18 @@ export const runUserCollectionDuplicatedSubscription = () =>
variables: {},
})
export const runUserRootCollectionsSortedSubscription = () =>
runGQLSubscription({
query: UserRootCollectionsSortedDocument,
variables: {},
})
export const runUserChildCollectionSortedSubscription = () =>
runGQLSubscription({
query: UserChildCollectionSortedDocument,
variables: {},
})
export const runUserRequestCreatedSubscription = () =>
runGQLSubscription({ query: UserRequestCreatedDocument, variables: {} })

View file

@ -4,6 +4,7 @@ import { runDispatchWithOutSyncing } from "../../lib/sync"
import {
exportUserCollectionsToJSON,
runUserChildCollectionSortedSubscription,
runUserCollectionCreatedSubscription,
runUserCollectionDuplicatedSubscription,
runUserCollectionMovedSubscription,
@ -14,6 +15,7 @@ import {
runUserRequestDeletedSubscription,
runUserRequestMovedSubscription,
runUserRequestUpdatedSubscription,
runUserRootCollectionsSortedSubscription,
} from "./collections.api"
import { collectionsSyncer, getStoreByCollectionType } from "./collections.sync"
@ -44,6 +46,8 @@ import {
saveRESTRequestAs,
setGraphqlCollections,
setRESTCollections,
sortRESTCollection,
sortRESTFolder,
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
@ -280,6 +284,10 @@ function setupSubscriptions() {
setupUserCollectionOrderUpdatedSubscription()
const userCollectionDuplicatedSub =
setupUserCollectionDuplicatedSubscription()
const userRootCollectionsSortedSub =
setupUserRootCollectionsSortedSubscription()
const userChildCollectionSortedSub =
setupUserChildCollectionSortedSubscription()
const userRequestCreatedSub = setupUserRequestCreatedSubscription()
const userRequestUpdatedSub = setupUserRequestUpdatedSubscription()
@ -293,6 +301,8 @@ function setupSubscriptions() {
userCollectionMovedSub,
userCollectionOrderUpdatedSub,
userCollectionDuplicatedSub,
userRootCollectionsSortedSub,
userChildCollectionSortedSub,
userRequestCreatedSub,
userRequestUpdatedSub,
userRequestDeletedSub,
@ -711,6 +721,56 @@ function setupUserCollectionDuplicatedSubscription() {
return userCollectionDuplicatedSub
}
const setupUserRootCollectionsSortedSubscription = () => {
const [userRootCollectionsSorted$, userRootCollectionsSortedSub] =
runUserRootCollectionsSortedSubscription()
userRootCollectionsSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userRootCollectionsSorted) {
const { sortOption } = res.right.userRootCollectionsSorted
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
sortRESTCollection(null, sortOrder)
}
})
}
})
return userRootCollectionsSortedSub
}
const setupUserChildCollectionSortedSubscription = () => {
const [userChildCollectionSorted$, userChildCollectionSortedSub] =
runUserChildCollectionSortedSubscription()
userChildCollectionSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userChildCollectionsSorted) {
const { parentCollectionID, sortOption } =
res.right.userChildCollectionsSorted
if (!parentCollectionID) return
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
const sourcePath = getCollectionPathFromCollectionID(
parentCollectionID,
restCollectionStore.value.state
)
if (!sourcePath) return
sortRESTFolder(sourcePath, sortOrder)
}
})
}
})
return userChildCollectionSortedSub
}
function setupUserRequestCreatedSubscription() {
const [userRequestCreated$, userRequestCreatedSub] =
runUserRequestCreatedSubscription()

View file

@ -29,12 +29,12 @@ import {
importUserCollectionsFromJSON,
moveUserCollection,
moveUserRequest,
renameUserCollection,
sortUserCollections,
updateUserCollection,
updateUserCollectionOrder,
} from "./collections.api"
import { ReqType } from "../../api/generated/graphql"
import { ReqType, SortOptions } from "../../api/generated/graphql"
import * as E from "fp-ts/Either"
@ -263,6 +263,34 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
updateUserCollection(collectionID, collection.name, JSON.stringify(data))
}
},
sortRESTCollection({ collectionPath, sortOrder }) {
// If collectionPath is empty, it means we're sorting the whole root collections else we're sorting a specific collection
const collectionID =
collectionPath !== null && collectionPath !== undefined
? navigateToFolderWithIndexPath(restCollectionStore.value.state, [
collectionPath,
])?.id
: null
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID ?? null, order)
},
sortRESTFolder({ path, sortOrder }) {
const collectionID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
if (collectionID) {
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID, order)
}
},
async addFolder({ name, path }) {
const parentCollection = navigateToFolderWithIndexPath(
restCollectionStore.value.state,

View file

@ -0,0 +1,9 @@
mutation SortUserCollections(
$parentCollectionID: ID
$sortOption: SortOptions!
) {
sortUserCollections(
parentCollectionID: $parentCollectionID
sortOption: $sortOption
)
}

View file

@ -0,0 +1,6 @@
subscription UserChildCollectionSorted {
userChildCollectionsSorted {
parentCollectionID
sortOption
}
}

View file

@ -0,0 +1,6 @@
subscription UserRootCollectionsSorted {
userRootCollectionsSorted {
parentCollectionID
sortOption
}
}

View file

@ -38,16 +38,15 @@ export const getSyncInitFunction = <T extends DispatchingStore<any, any>>(
let oldSyncStatus = shouldSyncValue()
// Start and stop the subscriptions according to the sync settings from profile
shouldSyncObservable &&
shouldSyncObservable.subscribe((newSyncStatus) => {
if (oldSyncStatus === true && newSyncStatus === false) {
stopListeningToSubscriptions()
} else if (oldSyncStatus === false && newSyncStatus === true) {
startListeningToSubscriptions()
}
shouldSyncObservable?.subscribe((newSyncStatus) => {
if (oldSyncStatus && !newSyncStatus) {
stopListeningToSubscriptions()
} else if (newSyncStatus) {
startListeningToSubscriptions()
}
oldSyncStatus = newSyncStatus
})
oldSyncStatus = newSyncStatus
})
function startStoreSync() {
store.dispatches$.subscribe((actionParams) => {

View file

@ -53,6 +53,10 @@ import {
RenameUserCollectionMutation,
RenameUserCollectionMutationVariables,
ReqType,
SortOptions,
SortUserCollectionsDocument,
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
UpdateGqlUserRequestDocument,
UpdateGqlUserRequestMutation,
UpdateGqlUserRequestMutationVariables,
@ -65,6 +69,7 @@ import {
UpdateUserCollectionOrderDocument,
UpdateUserCollectionOrderMutation,
UpdateUserCollectionOrderMutationVariables,
UserChildCollectionSortedDocument,
UserCollectionCreatedDocument,
UserCollectionDuplicatedDocument,
UserCollectionMovedDocument,
@ -75,6 +80,7 @@ import {
UserRequestDeletedDocument,
UserRequestMovedDocument,
UserRequestUpdatedDocument,
UserRootCollectionsSortedDocument,
} from "@api/generated/graphql"
export const createRESTRootUserCollection = (title: string, data?: string) =>
@ -213,6 +219,19 @@ export const duplicateUserCollection = (
reqType,
})()
export const sortUserCollections = (
parentCollectionID: string | null,
sortOption: SortOptions
) =>
runMutation<
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
""
>(SortUserCollectionsDocument, {
parentCollectionID,
sortOption,
})()
export const editUserRequest = (
requestID: string,
title: string,
@ -353,6 +372,18 @@ export const runUserCollectionDuplicatedSubscription = () =>
variables: {},
})
export const runUserRootCollectionsSortedSubscription = () =>
runGQLSubscription({
query: UserRootCollectionsSortedDocument,
variables: {},
})
export const runUserChildCollectionSortedSubscription = () =>
runGQLSubscription({
query: UserChildCollectionSortedDocument,
variables: {},
})
export const runUserRequestCreatedSubscription = () =>
runGQLSubscription({ query: UserRequestCreatedDocument, variables: {} })

View file

@ -4,6 +4,7 @@ import { runDispatchWithOutSyncing } from "@lib/sync"
import {
exportUserCollectionsToJSON,
runUserChildCollectionSortedSubscription,
runUserCollectionCreatedSubscription,
runUserCollectionDuplicatedSubscription,
runUserCollectionMovedSubscription,
@ -14,6 +15,7 @@ import {
runUserRequestDeletedSubscription,
runUserRequestMovedSubscription,
runUserRequestUpdatedSubscription,
runUserRootCollectionsSortedSubscription,
} from "./api"
import { collectionsSyncer, getStoreByCollectionType } from "./sync"
@ -44,6 +46,8 @@ import {
saveRESTRequestAs,
setGraphqlCollections,
setRESTCollections,
sortRESTCollection,
sortRESTFolder,
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
@ -288,6 +292,10 @@ function setupSubscriptions() {
setupUserCollectionOrderUpdatedSubscription()
const userCollectionDuplicatedSub =
setupUserCollectionDuplicatedSubscription()
const userRootCollectionsSortedSub =
setupUserRootCollectionsSortedSubscription()
const userChildCollectionSortedSub =
setupUserChildCollectionSortedSubscription()
const userRequestCreatedSub = setupUserRequestCreatedSubscription()
const userRequestUpdatedSub = setupUserRequestUpdatedSubscription()
@ -301,6 +309,8 @@ function setupSubscriptions() {
userCollectionMovedSub,
userCollectionOrderUpdatedSub,
userCollectionDuplicatedSub,
userRootCollectionsSortedSub,
userChildCollectionSortedSub,
userRequestCreatedSub,
userRequestUpdatedSub,
userRequestDeletedSub,
@ -725,6 +735,56 @@ function setupUserCollectionDuplicatedSubscription() {
return userCollectionDuplicatedSub
}
const setupUserRootCollectionsSortedSubscription = () => {
const [userRootCollectionsSorted$, userRootCollectionsSortedSub] =
runUserRootCollectionsSortedSubscription()
userRootCollectionsSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userRootCollectionsSorted) {
const { sortOption } = res.right.userRootCollectionsSorted
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
sortRESTCollection(null, sortOrder)
}
})
}
})
return userRootCollectionsSortedSub
}
const setupUserChildCollectionSortedSubscription = () => {
const [userChildCollectionSorted$, userChildCollectionSortedSub] =
runUserChildCollectionSortedSubscription()
userChildCollectionSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userChildCollectionsSorted) {
const { parentCollectionID, sortOption } =
res.right.userChildCollectionsSorted
if (!parentCollectionID) return
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
const sourcePath = getCollectionPathFromCollectionID(
parentCollectionID,
restCollectionStore.value.state
)
if (!sourcePath) return
sortRESTFolder(sourcePath, sortOrder)
}
})
}
})
return userChildCollectionSortedSub
}
function setupUserRequestCreatedSubscription() {
const [userRequestCreated$, userRequestCreatedSub] =
runUserRequestCreatedSubscription()

View file

@ -28,12 +28,13 @@ import {
importUserCollectionsFromJSON,
moveUserCollection,
moveUserRequest,
sortUserCollections,
updateUserCollection,
updateUserCollectionOrder,
} from "./api"
import * as E from "fp-ts/Either"
import { ReqType } from "@api/generated/graphql"
import { ReqType, SortOptions } from "@api/generated/graphql"
// restCollectionsMapper uses the collectionPath as the local identifier
// Helper function to transform HoppCollection to backend format
@ -267,6 +268,35 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
updateUserCollection(collectionID, collection.name, JSON.stringify(data))
}
},
sortRESTCollection({ collectionPath, sortOrder }) {
// If collectionPath is empty, it means we're sorting the whole root collections else we're sorting a specific collection
const collectionID =
collectionPath !== null && collectionPath !== undefined
? navigateToFolderWithIndexPath(restCollectionStore.value.state, [
collectionPath,
])?.id
: null
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID ?? null, order)
},
sortRESTFolder({ path, sortOrder }) {
const collectionID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
if (collectionID) {
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID, order)
}
},
async addFolder({ name, path }) {
const parentCollection = navigateToFolderWithIndexPath(
restCollectionStore.value.state,

View file

@ -53,6 +53,10 @@ import {
RenameUserCollectionMutation,
RenameUserCollectionMutationVariables,
ReqType,
SortOptions,
SortUserCollectionsDocument,
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
UpdateGqlUserRequestDocument,
UpdateGqlUserRequestMutation,
UpdateGqlUserRequestMutationVariables,
@ -65,6 +69,7 @@ import {
UpdateUserCollectionOrderDocument,
UpdateUserCollectionOrderMutation,
UpdateUserCollectionOrderMutationVariables,
UserChildCollectionSortedDocument,
UserCollectionCreatedDocument,
UserCollectionDuplicatedDocument,
UserCollectionMovedDocument,
@ -75,6 +80,7 @@ import {
UserRequestDeletedDocument,
UserRequestMovedDocument,
UserRequestUpdatedDocument,
UserRootCollectionsSortedDocument,
} from "@api/generated/graphql"
export const createRESTRootUserCollection = (title: string, data?: string) =>
@ -213,6 +219,19 @@ export const duplicateUserCollection = (
reqType,
})()
export const sortUserCollections = (
parentCollectionID: string | null,
sortOption: SortOptions
) =>
runMutation<
SortUserCollectionsMutation,
SortUserCollectionsMutationVariables,
""
>(SortUserCollectionsDocument, {
parentCollectionID,
sortOption,
})()
export const editUserRequest = (
requestID: string,
title: string,
@ -353,6 +372,18 @@ export const runUserCollectionDuplicatedSubscription = () =>
variables: {},
})
export const runUserRootCollectionsSortedSubscription = () =>
runGQLSubscription({
query: UserRootCollectionsSortedDocument,
variables: {},
})
export const runUserChildCollectionSortedSubscription = () =>
runGQLSubscription({
query: UserChildCollectionSortedDocument,
variables: {},
})
export const runUserRequestCreatedSubscription = () =>
runGQLSubscription({ query: UserRequestCreatedDocument, variables: {} })

View file

@ -4,6 +4,7 @@ import { runDispatchWithOutSyncing } from "@lib/sync"
import {
exportUserCollectionsToJSON,
runUserChildCollectionSortedSubscription,
runUserCollectionCreatedSubscription,
runUserCollectionDuplicatedSubscription,
runUserCollectionMovedSubscription,
@ -14,6 +15,7 @@ import {
runUserRequestDeletedSubscription,
runUserRequestMovedSubscription,
runUserRequestUpdatedSubscription,
runUserRootCollectionsSortedSubscription,
} from "./api"
import { collectionsSyncer, getStoreByCollectionType } from "./sync"
@ -44,6 +46,8 @@ import {
saveRESTRequestAs,
setGraphqlCollections,
setRESTCollections,
sortRESTCollection,
sortRESTFolder,
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
@ -166,6 +170,7 @@ function exportedCollectionToHoppCollection(
testScript,
requestVariables,
responses,
_ref_id,
} = request
const resolvedParams = addDescriptionField(params)
@ -185,6 +190,7 @@ function exportedCollectionToHoppCollection(
preRequestScript,
testScript,
responses,
_ref_id: _ref_id ?? generateUniqueRefId("req"),
}
}),
auth: data.auth,
@ -288,6 +294,10 @@ function setupSubscriptions() {
setupUserCollectionOrderUpdatedSubscription()
const userCollectionDuplicatedSub =
setupUserCollectionDuplicatedSubscription()
const userRootCollectionsSortedSub =
setupUserRootCollectionsSortedSubscription()
const userChildCollectionSortedSub =
setupUserChildCollectionSortedSubscription()
const userRequestCreatedSub = setupUserRequestCreatedSubscription()
const userRequestUpdatedSub = setupUserRequestUpdatedSubscription()
@ -301,6 +311,8 @@ function setupSubscriptions() {
userCollectionMovedSub,
userCollectionOrderUpdatedSub,
userCollectionDuplicatedSub,
userRootCollectionsSortedSub,
userChildCollectionSortedSub,
userRequestCreatedSub,
userRequestUpdatedSub,
userRequestDeletedSub,
@ -725,6 +737,56 @@ function setupUserCollectionDuplicatedSubscription() {
return userCollectionDuplicatedSub
}
const setupUserRootCollectionsSortedSubscription = () => {
const [userRootCollectionsSorted$, userRootCollectionsSortedSub] =
runUserRootCollectionsSortedSubscription()
userRootCollectionsSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userRootCollectionsSorted) {
const { sortOption } = res.right.userRootCollectionsSorted
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
sortRESTCollection(null, sortOrder)
}
})
}
})
return userRootCollectionsSortedSub
}
const setupUserChildCollectionSortedSubscription = () => {
const [userChildCollectionSorted$, userChildCollectionSortedSub] =
runUserChildCollectionSortedSubscription()
userChildCollectionSorted$.subscribe((res) => {
if (E.isRight(res)) {
runDispatchWithOutSyncing(() => {
if (res.right.userChildCollectionsSorted) {
const { parentCollectionID, sortOption } =
res.right.userChildCollectionsSorted
if (!parentCollectionID) return
const sortOrder = sortOption === "TITLE_ASC" ? "asc" : "desc"
const sourcePath = getCollectionPathFromCollectionID(
parentCollectionID,
restCollectionStore.value.state
)
if (!sourcePath) return
sortRESTFolder(sourcePath, sortOrder)
}
})
}
})
return userChildCollectionSortedSub
}
function setupUserRequestCreatedSubscription() {
const [userRequestCreated$, userRequestCreatedSub] =
runUserRequestCreatedSubscription()

View file

@ -28,12 +28,13 @@ import {
importUserCollectionsFromJSON,
moveUserCollection,
moveUserRequest,
sortUserCollections,
updateUserCollection,
updateUserCollectionOrder,
} from "./api"
import * as E from "fp-ts/Either"
import { ReqType } from "@api/generated/graphql"
import { ReqType, SortOptions } from "@api/generated/graphql"
// restCollectionsMapper uses the collectionPath as the local identifier
// Helper function to transform HoppCollection to backend format
@ -270,6 +271,36 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
updateUserCollection(collectionID, collection.name, JSON.stringify(data))
}
},
sortRESTCollection({ collectionPath, sortOrder }) {
// If collectionPath is empty, it means we're sorting the whole root collections else we're sorting a specific collection
const collectionID =
collectionPath !== null && collectionPath !== undefined
? navigateToFolderWithIndexPath(restCollectionStore.value.state, [
collectionPath,
])?.id
: null
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID ?? null, order)
},
sortRESTFolder({ path, sortOrder }) {
const collectionID = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((index) => parseInt(index))
)?.id
if (collectionID) {
const order =
sortOrder === "asc" ? SortOptions.TitleAsc : SortOptions.TitleDesc
sortUserCollections(collectionID, order)
}
},
async addFolder({ name, path }) {
const parentCollection = navigateToFolderWithIndexPath(
restCollectionStore.value.state,