From 81fe98f25d0e67db05eaf7c5e3af6f788cf6985d Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Tue, 23 Sep 2025 15:16:23 +0600 Subject: [PATCH] feature: add alphabetical sort for user and team collections (#5383) Co-authored-by: nivedin 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> --- packages/hoppscotch-backend/src/app.module.ts | 2 + packages/hoppscotch-backend/src/gql-schema.ts | 4 + .../sort/sort-team-collection.resolver.ts | 104 ++ .../sort/sort-user-collection.resolver.ts | 74 + .../src/orchestration/sort/sort.model.ts | 16 + .../src/orchestration/sort/sort.module.ts | 25 + .../orchestration/sort/sort.service.spec.ts | 180 +++ .../src/orchestration/sort/sort.service.ts | 92 ++ .../src/pubsub/topicsDefs.ts | 5 + .../team-collection.service.spec.ts | 70 + .../team-collection.service.ts | 54 + .../team-request/team-request.service.spec.ts | 91 +- .../src/team-request/team-request.service.ts | 54 + .../src/types/SortOptions.ts | 10 + .../user-collection.service.ts | 51 + .../src/user-request/user-request.module.ts | 1 + .../src/user-request/user-request.service.ts | 55 +- packages/hoppscotch-common/locales/en.json | 5 +- .../src/components/collections/Collection.vue | 113 +- .../components/collections/MyCollections.vue | 77 +- .../collections/TeamCollections.vue | 96 +- .../collections/graphql/AddFolder.vue | 2 +- .../collections/graphql/Collection.vue | 10 +- .../components/collections/graphql/Folder.vue | 11 +- .../components/collections/graphql/index.vue | 11 +- .../src/components/collections/index.vue | 209 +-- .../src/components/http/test/Runner.vue | 10 +- .../gql/mutations/SortTeamCollections.graphql | 11 + .../TeamChildCollectionSorted.graphql | 3 + .../TeamRootCollectionsSorted.graphql | 3 + .../backend/mutations/TeamCollection.ts | 19 + .../src/helpers/collection/collection.ts | 240 +--- .../helpers/curl/__tests__/curlparser.spec.js | 22 +- .../src/helpers/rest/document.ts | 10 +- .../helpers/types/HoppRequestSaveContext.ts | 8 + .../src/newstore/collections.ts | 107 ++ .../__tests__/current-sort.service.spec.ts | 96 ++ .../__tests__/workspace.service.spec.ts | 4 + .../src/services/current-sort.service.ts | 89 ++ .../persistence/__tests__/__mocks__/index.ts | 1 + .../src/services/persistence/index.ts | 56 + .../persistence/validation-schemas/index.ts | 16 +- .../src/services/tab/rest.ts | 9 +- .../src/services/team-collection.service.ts | 1207 +++++++++++++++++ packages/hoppscotch-data/src/rest/index.ts | 13 +- packages/hoppscotch-data/src/rest/v/16.ts | 23 + .../mutations/DuplicateUserCollection.graphql | 3 + .../api/mutations/SortUserCollections.graphql | 9 + .../UserChildCollectionSorted.graphql | 6 + .../UserRootCollectionsSorted.graphql | 6 + .../platform/collections/collections.api.ts | 47 + .../collections/collections.platform.ts | 60 + .../platform/collections/collections.sync.ts | 32 +- .../api/mutations/SortUserCollections.graphql | 9 + .../UserChildCollectionSorted.graphql | 6 + .../UserRootCollectionsSorted.graphql | 6 + .../src/lib/sync/index.ts | 17 +- .../src/platform/collections/desktop/api.ts | 31 + .../src/platform/collections/desktop/index.ts | 60 + .../src/platform/collections/desktop/sync.ts | 32 +- .../src/platform/collections/web/api.ts | 31 + .../src/platform/collections/web/index.ts | 62 + .../src/platform/collections/web/sync.ts | 33 +- 63 files changed, 3478 insertions(+), 341 deletions(-) create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts create mode 100644 packages/hoppscotch-backend/src/types/SortOptions.ts create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql create mode 100644 packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts create mode 100644 packages/hoppscotch-common/src/services/current-sort.service.ts create mode 100644 packages/hoppscotch-common/src/services/team-collection.service.ts create mode 100644 packages/hoppscotch-data/src/rest/v/16.ts create mode 100644 packages/hoppscotch-selfhost-desktop/src/api/mutations/DuplicateUserCollection.graphql create mode 100644 packages/hoppscotch-selfhost-desktop/src/api/mutations/SortUserCollections.graphql create mode 100644 packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserChildCollectionSorted.graphql create mode 100644 packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserRootCollectionsSorted.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserChildCollectionSorted.graphql create mode 100644 packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRootCollectionsSorted.graphql diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 52113c64..18859d8f 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -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, diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts index 87d6dffd..fc43384b 100644 --- a/packages/hoppscotch-backend/src/gql-schema.ts +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -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, ]; /** diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts new file mode 100644 index 00000000..f850b592 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort-team-collection.resolver.ts @@ -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 { + 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`); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts new file mode 100644 index 00000000..ee44ea92 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort-user-collection.resolver.ts @@ -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 { + 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`); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts new file mode 100644 index 00000000..acd13fdd --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.model.ts @@ -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; +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts new file mode 100644 index 00000000..94ff56cc --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.module.ts @@ -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 {} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts new file mode 100644 index 00000000..5a9db450 --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts @@ -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(); +const mockUserCollectionService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockTeamRequestService = mockDeep(); +const mockPubSub = mockDeep(); + +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)); + }); +}); diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts new file mode 100644 index 00000000..7f057e2a --- /dev/null +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.ts @@ -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); + } +} diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index d9b7b246..7c875955 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -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; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts index b4829396..6a91c80a 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -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(); const mockPubSub = mockDeep(); @@ -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', () => { diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index f1699028..bb1eabbf 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -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; + 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); + } } diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts index 54df1fa0..5388c84c 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -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(); const mockTeamService = mockDeep(); @@ -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)); }); }); diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts index c2259fd7..fe32f147 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -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; + 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); + } } diff --git a/packages/hoppscotch-backend/src/types/SortOptions.ts b/packages/hoppscotch-backend/src/types/SortOptions.ts new file mode 100644 index 00000000..9b1b0fba --- /dev/null +++ b/packages/hoppscotch-backend/src/types/SortOptions.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum SortOptions { + TITLE_ASC = 'TITLE_ASC', + TITLE_DESC = 'TITLE_DESC', +} + +registerEnumType(SortOptions, { + name: 'SortOptions', +}); diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index d8948400..a0bf35cd 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -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; + 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); + } } diff --git a/packages/hoppscotch-backend/src/user-request/user-request.module.ts b/packages/hoppscotch-backend/src/user-request/user-request.module.ts index b1c465d2..1022956d 100644 --- a/packages/hoppscotch-backend/src/user-request/user-request.module.ts +++ b/packages/hoppscotch-backend/src/user-request/user-request.module.ts @@ -11,5 +11,6 @@ import { UserRequestService } from './user-request.service'; UserRequestUserCollectionResolver, UserRequestService, ], + exports: [UserRequestService], }) export class UserRequestModule {} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.ts index 30a9e17a..1edfc584 100644 --- a/packages/hoppscotch-backend/src/user-request/user-request.service.ts +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.ts @@ -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.Right> { + if (!collectionID) return E.right(true); + + let orderBy: Prisma.Enumerable; + 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); + } } diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 7c4c60cf..07b36df8 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -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", diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index 11ef126d..41600319 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -58,7 +58,13 @@ -
+
+ +
+
+ + (), { 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(null) @@ -328,18 +365,80 @@ const exportAction = ref(null) const options = ref(null) const propertiesAction = ref(null) const runCollectionAction = ref(null) +const sortAction = ref(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 diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index afc1727b..7d088129 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -22,6 +22,14 @@ :title="t('app.wiki')" :icon="IconHelpCircle" /> + ( + 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 { diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index e734da4f..afa57e93 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -32,6 +32,20 @@ :title="t('app.wiki')" :icon="IconHelpCircle" /> + @@ -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 { } 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)) { diff --git a/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue b/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue index 71dd41a9..0d691047 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/AddFolder.vue @@ -43,7 +43,7 @@ const toast = useToast() const props = defineProps<{ show: boolean folderPath?: string - collectionIndex: number + collectionIndex?: number }>() const emit = defineEmits<{ diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue index 88a50aa0..df4d38b3 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Collection.vue @@ -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() diff --git a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue index 234d08b1..a2cb4e38 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/Folder.vue @@ -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)" /> diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue index e4497a5a..7577b121 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/index.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -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) diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 1bbc59b5..96f547ba 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -65,19 +65,21 @@ @select="selectPicked" @select-response="selectResponse" @select-request="selectRequest" + @sort-collections="sortCollections" @update-request-order="updateRequestOrder" @update-collection-order="updateCollectionOrder" /> + ([]) 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) => { + 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() diff --git a/packages/hoppscotch-common/src/components/http/test/Runner.vue b/packages/hoppscotch-common/src/components/http/test/Runner.vue index 0ab4eb35..5600a81a 100644 --- a/packages/hoppscotch-common/src/components/http/test/Runner.vue +++ b/packages/hoppscotch-common/src/components/http/test/Runner.vue @@ -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 }>() diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql new file mode 100644 index 00000000..c283f5ef --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/SortTeamCollections.graphql @@ -0,0 +1,11 @@ +mutation SortTeamCollections( + $teamID: ID! + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortTeamCollections( + teamID: $teamID + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql new file mode 100644 index 00000000..a7f2356d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamChildCollectionSorted.graphql @@ -0,0 +1,3 @@ +subscription TeamChildCollectionSorted($teamID: ID!) { + teamChildCollectionsSorted(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql new file mode 100644 index 00000000..ba2db410 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/subscriptions/TeamRootCollectionsSorted.graphql @@ -0,0 +1,3 @@ +subscription TeamRootCollectionsSorted($teamID: ID!) { + teamRootCollectionsSorted(teamID: $teamID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts index 47dd6d1c..be9a2892 100644 --- a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts @@ -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, + }) diff --git a/packages/hoppscotch-common/src/helpers/collection/collection.ts b/packages/hoppscotch-common/src/helpers/collection/collection.ts index 5bd965bc..e782b4b7 100644 --- a/packages/hoppscotch-common/src/helpers/collection/collection.ts +++ b/packages/hoppscotch-common/src/helpers/collection/collection.ts @@ -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 diff --git a/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js index 13192057..6b82956b 100644 --- a/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js +++ b/packages/hoppscotch-common/src/helpers/curl/__tests__/curlparser.spec.js @@ -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} + */ + 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)) }) } }) diff --git a/packages/hoppscotch-common/src/helpers/rest/document.ts b/packages/hoppscotch-common/src/helpers/rest/document.ts index 03195ba3..95c65589 100644 --- a/packages/hoppscotch-common/src/helpers/rest/document.ts +++ b/packages/hoppscotch-common/src/helpers/rest/document.ts @@ -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 diff --git a/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts index 0eddaa9e..9650fe43 100644 --- a/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts +++ b/packages/hoppscotch-common/src/helpers/types/HoppRequestSaveContext.ts @@ -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 } diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index 08c6e3d4..bd1e8119 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -235,6 +235,25 @@ function reorderItems(array: unknown[], from: number, to: number) { } } +function createComparator( + 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 diff --git a/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts new file mode 100644 index 00000000..60e25a30 --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/current-sort.service.spec.ts @@ -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({}) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts index 7bd53483..6720df17 100644 --- a/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts +++ b/packages/hoppscotch-common/src/services/__tests__/workspace.service.spec.ts @@ -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, }) }) }) diff --git a/packages/hoppscotch-common/src/services/current-sort.service.ts b/packages/hoppscotch-common/src/services/current-sort.service.ts new file mode 100644 index 00000000..b527c95b --- /dev/null +++ b/packages/hoppscotch-common/src/services/current-sort.service.ts @@ -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()) + + /** + * 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 + ) { + 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 = {} + this.currentSortOptions.forEach((option, id) => { + currentSortOptions[id] = option + }) + return currentSortOptions + }) +} diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index eb785597..bfe5dd6d 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -240,6 +240,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { body: { contentType: null, body: null }, requestVariables: [], responses: {}, + _ref_id: "req_ref_id", }, isDirty: false, type: "request", diff --git a/packages/hoppscotch-common/src/services/persistence/index.ts b/packages/hoppscotch-common/src/services/persistence/index.ts index a68ad0c0..55246ce5 100644 --- a/packages/hoppscotch-common/src/services/persistence/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/index.ts @@ -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( + 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) => { + await Store.set( + STORE_NAMESPACE, + STORE_KEYS.CURRENT_SORT_VALUES, + newData + ) + }, + { debounce: 500 } + ) + } + private async setupWebsocketPersistence() { const loadResult = await Store.get( STORE_NAMESPACE, @@ -986,6 +1040,8 @@ export class PersistenceService extends Service { this.setupSecretEnvironmentsPersistence(), this.setupCurrentEnvironmentValuePersistence(), + + this.setupCurrentSortValuesPersistence(), ]) } diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index 21750714..d8a5d9b3 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -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(), ]) diff --git a/packages/hoppscotch-common/src/services/tab/rest.ts b/packages/hoppscotch-common/src/services/tab/rest.ts index b19e16f1..9a0ad0b3 100644 --- a/packages/hoppscotch-common/src/services/tab/rest.ts +++ b/packages/hoppscotch-common/src/services/tab/rest.ts @@ -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 { ) { 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) } } diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts new file mode 100644 index 00000000..4b1cfad0 --- /dev/null +++ b/packages/hoppscotch-common/src/services/team-collection.service.ts @@ -0,0 +1,1207 @@ +import * as E from "fp-ts/Either" +import { Subscription } from "rxjs" +import { + HoppCollectionVariable, + HoppRESTAuth, + HoppRESTHeader, + translateToNewRequest, +} from "@hoppscotch/data" +import { pull, remove } from "lodash-es" +import { Subscription as WSubscription } from "wonka" +import { + RootCollectionsOfTeamDocument, + TeamCollectionAddedDocument, + TeamCollectionUpdatedDocument, + TeamCollectionRemovedDocument, + TeamRequestAddedDocument, + TeamRequestUpdatedDocument, + TeamRequestDeletedDocument, + GetCollectionChildrenDocument, + GetCollectionRequestsDocument, + TeamRequestMovedDocument, + TeamCollectionMovedDocument, + TeamRequestOrderUpdatedDocument, + TeamCollectionOrderUpdatedDocument, + TeamRootCollectionsSortedDocument, + TeamChildCollectionSortedDocument, +} from "~/helpers/backend/graphql" +import { SecretEnvironmentService } from "~/services/secret-environment.service" +import { CurrentValueService } from "~/services/current-environment-value.service" +import { TeamCollection } from "~/helpers/teams/TeamCollection" +import { TeamRequest } from "~/helpers/teams/TeamRequest" +import { runGQLQuery, runGQLSubscription } from "~/helpers/backend/GQLClient" +import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { WorkspaceService } from "./workspace.service" +import { ref, watch } from "vue" +import { Service } from "dioc" + +export const TEAMS_BACKEND_PAGE_SIZE = 10 + +const findParentOfColl = ( + tree: TeamCollection[], + collID: string, + currentParent?: TeamCollection +): TeamCollection | null => { + for (const coll of tree) { + // If the root is parent, return null + if (coll.id === collID) return currentParent || null + + // Else run it in children + if (coll.children) { + const result = findParentOfColl(coll.children, collID, coll) + if (result) return result + } + } + + return null +} + +const findCollInTree = ( + tree: TeamCollection[], + targetID: string +): TeamCollection | null => { + for (const coll of tree) { + if (coll.id === targetID) return coll + + if (coll.children) { + const result = findCollInTree(coll.children, targetID) + if (result) return result + } + } + + return null +} + +const deleteCollInTree = (tree: TeamCollection[], targetID: string) => { + const parent = findParentOfColl(tree, targetID) + + if (parent && parent.children) { + parent.children = parent.children.filter((coll) => coll.id !== targetID) + } + + const el = findCollInTree(tree, targetID) + if (!el) return + + pull(tree, el) +} + +const updateCollInTree = ( + tree: TeamCollection[], + updateColl: Partial & Pick +) => { + const el = findCollInTree(tree, updateColl.id) + if (!el) return + Object.assign(el, updateColl) +} + +const findReqInTree = ( + tree: TeamCollection[], + reqID: string +): TeamRequest | null => { + for (const coll of tree) { + if (coll.requests) { + const match = coll.requests.find((req) => req.id === reqID) + if (match) return match + } + + if (coll.children) { + const match = findReqInTree(coll.children, reqID) + if (match) return match + } + } + + return null +} + +const findCollWithReqIDInTree = ( + tree: TeamCollection[], + reqID: string +): TeamCollection | null => { + for (const coll of tree) { + if (coll.requests) { + if (coll.requests.find((req) => req.id === reqID)) return coll + } + + if (coll.children) { + const result = findCollWithReqIDInTree(coll.children, reqID) + if (result) return result + } + } + + return null +} + +export class TeamCollectionsService extends Service { + public static readonly ID = "TEAM_COLLECTIONS_SERVICE" + + //collection variables current value and secret value services + private secretEnvironmentService = this.bind(SecretEnvironmentService) + private currentEnvironmentValueService = this.bind(CurrentValueService) + + private workspaceService = this.bind(WorkspaceService) + + private teamID: string | null = null + + public collections = ref([]) + public loadingCollections = ref([]) + + private entityIDs: Set = new Set() + + private teamCollectionAdded$: Subscription | null = null + private teamCollectionUpdated$: Subscription | null = null + private teamCollectionRemoved$: Subscription | null = null + private teamRequestAdded$: Subscription | null = null + private teamRequestUpdated$: Subscription | null = null + private teamRequestDeleted$: Subscription | null = null + private teamRequestMoved$: Subscription | null = null + private teamCollectionMoved$: Subscription | null = null + private teamRequestOrderUpdated$: Subscription | null = null + private teamCollectionOrderUpdated$: Subscription | null = null + private teamRootCollectionSorted$: Subscription | null = null + private teamChildCollectionSorted$: Subscription | null = null + + private teamCollectionAddedSub: WSubscription | null = null + private teamCollectionUpdatedSub: WSubscription | null = null + private teamCollectionRemovedSub: WSubscription | null = null + private teamRequestAddedSub: WSubscription | null = null + private teamRequestUpdatedSub: WSubscription | null = null + private teamRequestDeletedSub: WSubscription | null = null + private teamRequestMovedSub: WSubscription | null = null + private teamCollectionMovedSub: WSubscription | null = null + private teamRequestOrderUpdatedSub: WSubscription | null = null + private teamCollectionOrderUpdatedSub: WSubscription | null = null + private teamRootCollectionSortedSub: WSubscription | null = null + private teamChildCollectionSortedSub: WSubscription | null = null + + override onServiceInit() { + // Watch for team change and update the collections accordingly + watch( + () => this.workspaceService.currentWorkspace, + (workspace) => { + if (workspace.value.type === "team" && workspace.value.teamID) { + this.changeTeamID(workspace.value.teamID) + } else { + this.clearCollections() + } + }, + { immediate: true, deep: true } + ) + } + + changeTeamID(newTeamID: string | null) { + this.teamID = newTeamID + this.collections.value = [] + this.entityIDs.clear() + + this.loadingCollections.value = [] + + this.unsubscribeSubscriptions() + + if (this.teamID) this.initialize() + } + + /** + * Unsubscribes from the subscriptions + * NOTE: Once this is called, no new updates to the tree will be detected + */ + unsubscribeSubscriptions() { + this.teamCollectionAdded$?.unsubscribe() + this.teamCollectionUpdated$?.unsubscribe() + this.teamCollectionRemoved$?.unsubscribe() + this.teamRequestAdded$?.unsubscribe() + this.teamRequestDeleted$?.unsubscribe() + this.teamRequestUpdated$?.unsubscribe() + this.teamRequestMoved$?.unsubscribe() + this.teamCollectionMoved$?.unsubscribe() + this.teamRequestOrderUpdated$?.unsubscribe() + this.teamCollectionOrderUpdated$?.unsubscribe() + this.teamRootCollectionSorted$?.unsubscribe() + this.teamChildCollectionSorted$?.unsubscribe() + + this.teamCollectionAddedSub?.unsubscribe() + this.teamCollectionUpdatedSub?.unsubscribe() + this.teamCollectionRemovedSub?.unsubscribe() + this.teamRequestAddedSub?.unsubscribe() + this.teamRequestDeletedSub?.unsubscribe() + this.teamRequestUpdatedSub?.unsubscribe() + this.teamRequestMovedSub?.unsubscribe() + this.teamCollectionMovedSub?.unsubscribe() + this.teamRequestOrderUpdatedSub?.unsubscribe() + this.teamCollectionOrderUpdatedSub?.unsubscribe() + this.teamRootCollectionSortedSub?.unsubscribe() + this.teamChildCollectionSortedSub?.unsubscribe() + } + + private async initialize() { + await this.loadRootCollections() + this.registerSubscriptions() + } + + /** + * Performs addition of a collection to the tree + * + * @param {TeamCollection} collection - The collection to add to the tree + * @param {string | null} parentCollectionID - The parent of the new collection, pass null if this collection is in root + */ + private addCollection( + collection: TeamCollection, + parentCollectionID: string | null + ) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`collection-${collection.id}`)) return + + const tree = this.collections.value + + if (!parentCollectionID) { + tree.push(collection) + } else { + const parentCollection = findCollInTree(tree, parentCollectionID) + + if (!parentCollection) return + + // Prevent adding child collections to a collection that has not been expanded yet incoming from GQL subscription, during import, etc + // Hence, add entries to the pre-existing list without setting 'children' if it is `null' + if (parentCollection.children !== null) { + parentCollection.children.push(collection) + } + } + + // Add to entity ids set + this.entityIDs.add(`collection-${collection.id}`) + + this.collections.value = tree + } + + private clearCollections() { + this.collections.value = [] + this.entityIDs.clear() + this.loadingCollections.value = [] + this.unsubscribeSubscriptions() + this.teamID = null + } + + /** + * Loads the root collections of the current team + * @param replace Whether to replace the existing collections or append to them + * We might want to replace when we are reloading the collections like when sorting the whole root collections + */ + private async loadRootCollections(replace = false) { + if (this.teamID === null) throw new Error("Team ID is null") + + this.loadingCollections.value.push("root") + + const totalCollections: TeamCollection[] = [] + + while (true) { + const result = await runGQLQuery({ + query: RootCollectionsOfTeamDocument, + variables: { + teamID: this.teamID, + cursor: + totalCollections.length > 0 + ? totalCollections[totalCollections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(result)) { + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== "root" + ) + + throw new Error( + `Error fetching root collections: ${result.left?.error}` + ) + } + + if (replace) { + this.collections.value = [] + this.entityIDs.clear() + + totalCollections.push( + ...result.right.rootCollectionsOfTeam.map( + (x: any) => + { + ...x, + children: null, + requests: null, + } + ) + ) + } else { + totalCollections.push( + ...result.right.rootCollectionsOfTeam.map( + (x: any) => + { + ...x, + children: null, + requests: null, + } + ) + ) + } + + if (result.right.rootCollectionsOfTeam.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== "root" + ) + + // Add all the collections to the entity ids list + totalCollections.forEach((coll) => + this.entityIDs.add(`collection-${coll.id}`) + ) + + this.collections.value.push(...totalCollections) + } + + /** + * Updates an existing collection in tree + * + * @param {Partial & Pick} collectionUpdate - Object defining the fields that need to be updated (ID is required to find the target) + */ + private updateCollection( + collectionUpdate: Partial & Pick + ) { + const tree = this.collections.value + + updateCollInTree(tree, collectionUpdate) + + this.collections.value = tree + } + + /** + * Removes a collection from the tree + * + * @param {string} collectionID - ID of the collection to remove + */ + private removeCollection(collectionID: string) { + const tree = this.collections.value + + deleteCollInTree(tree, collectionID) + + this.entityIDs.delete(`collection-${collectionID}`) + + this.collections.value = tree + } + + /** + * Adds a request to the tree + * + * @param {TeamRequest} request - The request to add to the tree + */ + private addRequest(request: TeamRequest) { + // Check if we have it already in the entity tree, if so, we don't need it again + if (this.entityIDs.has(`request-${request.id}`)) return + + const tree = this.collections.value + + // Check if we have the collection (if not, then not loaded?) + const coll = findCollInTree(tree, request.collectionID) + if (!coll) return // Ignore add request + + // Collection is not expanded + if (!coll.requests) return + + // Collection is expanded hence append request + coll.requests.push(request) + + // Update the Entity IDs list + this.entityIDs.add(`request-${request.id}`) + + this.collections.value = tree + } + + /** + * Updates the request in tree + * + * @param {Partial & Pick} requestUpdate - Object defining all the fields to update in request (ID of the request is required) + */ + private updateRequest( + requestUpdate: Partial & Pick + ) { + const tree = this.collections.value + + // Find request, if not present, don't update + const req = findReqInTree(tree, requestUpdate.id) + if (!req) return + + Object.assign(req, requestUpdate) + + this.collections.value = tree + } + + /** + * Removes a request from the tree + * + * @param {string} requestID - ID of the request to remove + */ + private removeRequest(requestID: string) { + const tree = this.collections.value + + // Find request in tree, don't attempt if no collection or no requests (expansion?) + const coll = findCollWithReqIDInTree(tree, requestID) + if (!coll || !coll.requests) return + + // Remove the collection + remove(coll.requests, (req: any) => req.id === requestID) + + // Remove from entityIDs set + this.entityIDs.delete(`request-${requestID}`) + + // Publish new tree + this.collections.value = tree + } + + /** + * Moves a request from one collection to another + * + * @param {string} request - The request to move + */ + private async moveRequest(request: TeamRequest) { + const tree = this.collections.value + + // Remove the request from the current collection + this.removeRequest(request.id) + + const currentRequest = request.request + + if (currentRequest === null || currentRequest === undefined) return + + // Find request in tree, don't attempt if no collection or no requests is found + const collection = findCollInTree(tree, request.collectionID) + if (!collection) return // Ignore add request + + // Collection is not expanded + if (!collection.requests) return + + this.addRequest({ + id: request.id, + collectionID: request.collectionID, + request: translateToNewRequest(request.request), + title: request.title, + }) + } + + /** + * Moves a collection from one collection to another or to root + * + * @param {string} collectionID - The ID of the collection to move + */ + private async moveCollection( + collectionID: string, + parentID: string | null, + title: string, + data?: string | null + ) { + // Remove the collection from the current position + this.removeCollection(collectionID) + + if (collectionID === null || parentID === undefined) return + + // Expand the parent collection if it is not expanded + // so that the old children is also visible when expanding + if (parentID) this.expandCollection(parentID) + + this.addCollection( + { + id: collectionID, + children: null, + requests: null, + title: title, + data, + }, + parentID ?? null + ) + } + + private reorderItems = (array: unknown[], from: number, to: number) => { + const item = array.splice(from, 1)[0] + if (from < to) { + array.splice(to - 1, 0, item) + } else { + array.splice(to, 0, item) + } + } + + public updateRequestOrder( + dragedRequestID: string, + destinationRequestID: string | null, + destinationCollectionID: string + ) { + const tree = this.collections.value + + // If the destination request is null, then it is the last request in the collection + if (destinationRequestID === null) { + const collection = findCollInTree(tree, destinationCollectionID) + + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + + // If the collection index is not found, don't update + if (requestIndex === -1) return + + // Move the request to the end of the requests + collection.requests.push(collection.requests.splice(requestIndex, 1)[0]) + } else { + // Find collection in tree, don't attempt if no collection is found + const collection = findCollInTree(tree, destinationCollectionID) + if (!collection) return // Ignore order update + + // Collection is not expanded + if (!collection.requests) return + + const requestIndex = collection.requests.findIndex( + (req) => req.id === dragedRequestID + ) + const destinationIndex = collection.requests.findIndex( + (req) => req.id === destinationRequestID + ) + + if (requestIndex === -1) return + + this.reorderItems(collection.requests, requestIndex, destinationIndex) + } + + this.collections.value = tree + } + + public updateCollectionOrder = ( + collectionID: string, + destinationCollectionID: string | null + ) => { + const tree = this.collections.value + + // If the destination collection is null, then it is the last collection in the tree + if (destinationCollectionID === null) { + const collLast = findParentOfColl(tree, collectionID) + if (collLast && collLast.children) { + const collectionIndex = collLast.children.findIndex( + (coll) => coll.id === collectionID + ) + + // reorder the collection to the end of the collections + collLast.children.push(collLast.children.splice(collectionIndex, 1)[0]) + } else { + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + // reorder the collection to the end of the collections in the root + tree.push(tree.splice(collectionIndex, 1)[0]) + } + } else { + // Find collection in tree + const coll = findParentOfColl(tree, destinationCollectionID) + + // If the collection has a parent collection and check if it has children + if (coll && coll.children) { + const collectionIndex = coll.children.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = coll.children.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(coll.children, collectionIndex, destinationIndex) + } else { + // If the collection has no parent collection, it is a root collection + const collectionIndex = tree.findIndex( + (coll) => coll.id === collectionID + ) + + const destinationIndex = tree.findIndex( + (coll) => coll.id === destinationCollectionID + ) + + // If the collection index is not found, don't update + if (collectionIndex === -1) return + + this.reorderItems(tree, collectionIndex, destinationIndex) + } + } + + this.collections.value = tree + } + + private registerSubscriptions() { + if (!this.teamID) return + + const [teamCollAdded$, teamCollAddedSub] = runGQLSubscription({ + query: TeamCollectionAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionAddedSub = teamCollAddedSub + + this.teamCollectionAdded$ = teamCollAdded$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Added Error: ${JSON.stringify(result.left)}` + ) + + this.addCollection( + { + id: result.right.teamCollectionAdded.id, + children: null, + requests: null, + title: result.right.teamCollectionAdded.title, + data: result.right.teamCollectionAdded.data ?? null, + }, + result.right.teamCollectionAdded.parent?.id ?? null + ) + }) + + const [teamCollUpdated$, teamCollUpdatedSub] = runGQLSubscription({ + query: TeamCollectionUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionUpdatedSub = teamCollUpdatedSub + this.teamCollectionUpdated$ = teamCollUpdated$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateCollection({ + id: result.right.teamCollectionUpdated.id, + title: result.right.teamCollectionUpdated.title, + data: result.right.teamCollectionUpdated.data, + }) + }) + + const [teamCollRemoved$, teamCollRemovedSub] = runGQLSubscription({ + query: TeamCollectionRemovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionRemovedSub = teamCollRemovedSub + this.teamCollectionRemoved$ = teamCollRemoved$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Removed Error: ${JSON.stringify(result.left)}` + ) + + this.removeCollection(result.right.teamCollectionRemoved) + }) + + const [teamReqAdded$, teamReqAddedSub] = runGQLSubscription({ + query: TeamRequestAddedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestAddedSub = teamReqAddedSub + this.teamRequestAdded$ = teamReqAdded$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Added Error: ${JSON.stringify(result.left)}` + ) + + this.addRequest({ + id: result.right.teamRequestAdded.id, + collectionID: result.right.teamRequestAdded.collectionID, + request: translateToNewRequest( + JSON.parse(result.right.teamRequestAdded.request) + ), + title: result.right.teamRequestAdded.title, + }) + }) + + const [teamReqUpdated$, teamReqUpdatedSub] = runGQLSubscription({ + query: TeamRequestUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestUpdatedSub = teamReqUpdatedSub + this.teamRequestUpdated$ = teamReqUpdated$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Updated Error: ${JSON.stringify(result.left)}` + ) + + this.updateRequest({ + id: result.right.teamRequestUpdated.id, + collectionID: result.right.teamRequestUpdated.collectionID, + request: JSON.parse(result.right.teamRequestUpdated.request), + title: result.right.teamRequestUpdated.title, + }) + }) + + const [teamReqDeleted$, teamReqDeletedSub] = runGQLSubscription({ + query: TeamRequestDeletedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestDeletedSub = teamReqDeletedSub + this.teamRequestDeleted$ = teamReqDeleted$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Deleted Error ${JSON.stringify(result.left)}` + ) + + this.removeRequest(result.right.teamRequestDeleted) + }) + + const [teamRequestMoved$, teamRequestMovedSub] = runGQLSubscription({ + query: TeamRequestMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestMovedSub = teamRequestMovedSub + this.teamRequestMoved$ = teamRequestMoved$.subscribe((result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Move Error ${JSON.stringify(result.left)}` + ) + + const { requestMoved } = result.right + + const request = { + id: requestMoved.id, + collectionID: requestMoved.collectionID, + title: requestMoved.title, + request: JSON.parse(requestMoved.request), + } + + this.moveRequest(request) + }) + + const [teamCollectionMoved$, teamCollectionMovedSub] = runGQLSubscription({ + query: TeamCollectionMovedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionMovedSub = teamCollectionMovedSub + this.teamCollectionMoved$ = teamCollectionMoved$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Move Error ${JSON.stringify(result.left)}` + ) + + const { teamCollectionMoved } = result.right + const { id, parent, title, data } = teamCollectionMoved + + const parentID = parent?.id ?? null + + this.moveCollection(id, parentID, title, data) + } + ) + + const [teamRequestOrderUpdated$, teamRequestOrderUpdatedSub] = + runGQLSubscription({ + query: TeamRequestOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRequestOrderUpdatedSub = teamRequestOrderUpdatedSub + this.teamRequestOrderUpdated$ = teamRequestOrderUpdated$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Request Order Update Error ${JSON.stringify(result.left)}` + ) + + const { requestOrderUpdated } = result.right + const { request } = requestOrderUpdated + const { nextRequest } = requestOrderUpdated + + this.updateRequestOrder( + request.id, + nextRequest ? nextRequest.id : null, + nextRequest ? nextRequest.collectionID : request.collectionID + ) + } + ) + + const [teamCollectionOrderUpdated$, teamCollectionOrderUpdatedSub] = + runGQLSubscription({ + query: TeamCollectionOrderUpdatedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamCollectionOrderUpdatedSub = teamCollectionOrderUpdatedSub + this.teamCollectionOrderUpdated$ = teamCollectionOrderUpdated$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Collection Order Update Error ${JSON.stringify(result.left)}` + ) + + const { collectionOrderUpdated } = result.right + const { collection } = collectionOrderUpdated + const { nextCollection } = collectionOrderUpdated + + this.updateCollectionOrder( + collection.id, + nextCollection ? nextCollection.id : null + ) + } + ) + + const [teamRootCollectionSorted$, teamRootCollectionSortedSub] = + runGQLSubscription({ + query: TeamRootCollectionsSortedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamRootCollectionSortedSub = teamRootCollectionSortedSub + this.teamRootCollectionSorted$ = teamRootCollectionSorted$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Root Collection Sorted Error ${JSON.stringify(result.left)}` + ) + + this.loadRootCollections(true) + } + ) + + const [teamChildCollectionSorted$, teamChildCollectionSortedSub] = + runGQLSubscription({ + query: TeamChildCollectionSortedDocument, + variables: { + teamID: this.teamID, + }, + }) + + this.teamChildCollectionSortedSub = teamChildCollectionSortedSub + this.teamChildCollectionSorted$ = teamChildCollectionSorted$.subscribe( + (result: any) => { + if (E.isLeft(result)) + throw new Error( + `Team Child Collection Sorted Error ${JSON.stringify(result.left)}` + ) + + const { teamChildCollectionsSorted } = result.right + + if (teamChildCollectionsSorted) { + this.expandCollection(teamChildCollectionsSorted, true) + } + } + ) + } + + private async getCollectionChildren( + collection: TeamCollection + ): Promise { + const collections: TeamCollection[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionChildrenDocument, + variables: { + collectionID: collection.id, + cursor: + collections.length > 0 + ? collections[collections.length - 1].id + : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error( + `Child Collection Fetch Error for ${collection.id}: ${data.left}` + ) + } + + collections.push( + ...data.right.collection!.children.map( + (el: any) => + { + id: el.id, + title: el.title, + data: el.data, + children: null, + requests: null, + } + ) + ) + + if (data.right.collection!.children.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return collections + } + + private async getCollectionRequests( + collection: TeamCollection + ): Promise { + const requests: TeamRequest[] = [] + + while (true) { + const data = await runGQLQuery({ + query: GetCollectionRequestsDocument, + variables: { + collectionID: collection.id, + cursor: + requests.length > 0 ? requests[requests.length - 1].id : undefined, + }, + }) + + if (E.isLeft(data)) { + throw new Error(`Child Request Fetch Error for ${data}: ${data.left}`) + } + + requests.push( + ...data.right.requestsInCollection.map((el: any) => { + return { + id: el.id, + collectionID: collection.id, + title: el.title, + request: translateToNewRequest(JSON.parse(el.request)), + } + }) + ) + + if (data.right.requestsInCollection.length !== TEAMS_BACKEND_PAGE_SIZE) + break + } + + return requests + } + + /** + * Expands a collection on the tree + * + * When a collection is loaded initially in the adapter, children and requests are not loaded (they will be set to null) + * Upon expansion those two fields will be populated + * + * @param {string} collectionID - The ID of the collection to expand + * @param {boolean} reFetch - Whether to re-fetch the children and requests even if they are already loaded (used in sorting scenarios where order might have changed) + */ + async expandCollection(collectionID: string, reFetch = false): Promise { + if (this.loadingCollections.value.includes(collectionID)) return + + const tree = this.collections.value + + const collection = findCollInTree(tree, collectionID) + + if (!collection) return + + if (collection.children !== null && !reFetch) return + + this.loadingCollections.value.push(collectionID) + + try { + const [collections, requests] = await Promise.all([ + this.getCollectionChildren(collection), + this.getCollectionRequests(collection), + ]) + + collection.children = collections + collection.requests = requests + + // Add to the entity ids set + collections.forEach((coll) => this.entityIDs.add(`collection-${coll.id}`)) + requests.forEach((req) => this.entityIDs.add(`request-${req.id}`)) + + this.collections.value = [...tree] + } finally { + this.loadingCollections.value = this.loadingCollections.value.filter( + (x) => x !== collectionID + ) + } + } + + private getCurrentValue = ( + env: HoppCollectionVariable, + varIndex: number, + collectionID: string + ) => { + if (env && env.secret) { + return this.secretEnvironmentService.getSecretEnvironmentVariable( + collectionID, + varIndex + )?.value + } + return this.currentEnvironmentValueService.getEnvironmentVariable( + collectionID, + varIndex + )?.currentValue + } + + /** + * This function populates the values of the variables with the current values or secrets. + * @param variables Variables to populate + * @returns Populated variables with current values or secrets + */ + private populateValues( + variables: HoppCollectionVariable[], + parentID: string + ) { + return variables.map((v, index) => ({ + ...v, + currentValue: this.getCurrentValue(v, index, parentID) ?? v.currentValue, + })) + } + + /** + * Used to obtain the inherited auth and headers for a given folder path, used for both REST and GraphQL team collections + * @param folderPath the path of the folder to cascade the auth from + * @returns the inherited auth and headers for the given folder path + */ + public cascadeParentCollectionForProperties(folderPath: string) { + let auth: HoppInheritedProperty["auth"] = { + parentID: folderPath ?? "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + } + const headers: HoppInheritedProperty["headers"] = [] + + const variables: HoppInheritedProperty["variables"] = [] + + if (!folderPath) return { auth, headers, variables } + + const path = folderPath.split("/") + + // Check if the path is empty or invalid + if (!path || path.length === 0) { + console.error("Invalid path:", folderPath) + return { auth, headers, variables } + } + + // Loop through the path and get the last parent folder with authType other than 'inherit' + for (let i = 0; i < path.length; i++) { + const parentFolder = findCollInTree(this.collections.value, path[i]) + + // Check if parentFolder is undefined or null + if (!parentFolder) { + console.error("Parent folder not found for path:", path) + return { auth, headers, variables } + } + + const data: { + auth: HoppRESTAuth + headers: HoppRESTHeader[] + variables: HoppCollectionVariable[] + } = parentFolder.data + ? JSON.parse(parentFolder.data) + : { + auth: null, + headers: null, + variables: null, + } + + if (!data.auth) { + data.auth = { + authType: "inherit", + authActive: true, + } + auth.parentID = path.slice(0, i + 1).join("/") + auth.parentName = parentFolder.title + } + + if (!data.headers) data.headers = [] + + if (!data.variables) data.variables = [] + + const parentFolderAuth = data.auth + const parentFolderHeaders = data.headers + const parentFolderVariables = data.variables + + if ( + parentFolderAuth?.authType === "inherit" && + path.slice(0, i + 1).length === 1 + ) { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: auth.inheritedAuth, + } + } + + if (parentFolderAuth?.authType !== "inherit") { + auth = { + parentID: path.slice(0, i + 1).join("/"), + parentName: parentFolder.title, + inheritedAuth: parentFolderAuth, + } + } + + // Update headers, overwriting duplicates by key + if (parentFolderHeaders) { + const activeHeaders = parentFolderHeaders.filter((h) => h.active) + activeHeaders.forEach((header) => { + const index = headers.findIndex( + (h) => h.inheritedHeader?.key === header.key + ) + const currentPath = path.slice(0, i + 1).join("/") + if (index !== -1) { + // Replace the existing header with the same key + headers[index] = { + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + } + } else { + headers.push({ + parentID: currentPath, + parentName: parentFolder.title, + inheritedHeader: header, + }) + } + }) + } + + // Update variables, overwriting duplicates by key + if (parentFolderVariables) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + variables.push({ + parentPath: path.slice(0, i + 1).join("/"), + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + inheritedVariables: this.populateValues( + parentFolderVariables, + parentFolder.id ?? currentPath + ), + }) + } + } + + return { auth, headers, variables } + } +} diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index e429378f..d2014249 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -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({ 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"), } } diff --git a/packages/hoppscotch-data/src/rest/v/16.ts b/packages/hoppscotch-data/src/rest/v/16.ts new file mode 100644 index 00000000..79f85ded --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/16.ts @@ -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) { + return { + ...old, + v: "16" as const, + _ref_id: old._ref_id ?? generateUniqueRefId("req"), + } + }, +}) + +export default V16_VERSION diff --git a/packages/hoppscotch-selfhost-desktop/src/api/mutations/DuplicateUserCollection.graphql b/packages/hoppscotch-selfhost-desktop/src/api/mutations/DuplicateUserCollection.graphql new file mode 100644 index 00000000..78d68bcd --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/api/mutations/DuplicateUserCollection.graphql @@ -0,0 +1,3 @@ +mutation DuplicateUserCollection($collectionID: String!, $reqType: ReqType!) { + duplicateUserCollection(collectionID: $collectionID, reqType: $reqType) +} diff --git a/packages/hoppscotch-selfhost-desktop/src/api/mutations/SortUserCollections.graphql b/packages/hoppscotch-selfhost-desktop/src/api/mutations/SortUserCollections.graphql new file mode 100644 index 00000000..6417fd9d --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/api/mutations/SortUserCollections.graphql @@ -0,0 +1,9 @@ +mutation SortUserCollections( + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortUserCollections( + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserChildCollectionSorted.graphql b/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserChildCollectionSorted.graphql new file mode 100644 index 00000000..595dbeca --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserChildCollectionSorted.graphql @@ -0,0 +1,6 @@ +subscription UserChildCollectionSorted { + userChildCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserRootCollectionsSorted.graphql b/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserRootCollectionsSorted.graphql new file mode 100644 index 00000000..ed77dd45 --- /dev/null +++ b/packages/hoppscotch-selfhost-desktop/src/api/subscriptions/UserRootCollectionsSorted.graphql @@ -0,0 +1,6 @@ +subscription UserRootCollectionsSorted { + userRootCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.api.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.api.ts index d08af2b4..1d6c678a 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.api.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.api.ts @@ -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: {} }) diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts index e3acf2ab..e774fb4e 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts @@ -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() diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts index 38705f60..cd633ca9 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts @@ -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, diff --git a/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql b/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql new file mode 100644 index 00000000..6417fd9d --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/mutations/SortUserCollections.graphql @@ -0,0 +1,9 @@ +mutation SortUserCollections( + $parentCollectionID: ID + $sortOption: SortOptions! +) { + sortUserCollections( + parentCollectionID: $parentCollectionID + sortOption: $sortOption + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserChildCollectionSorted.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserChildCollectionSorted.graphql new file mode 100644 index 00000000..595dbeca --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserChildCollectionSorted.graphql @@ -0,0 +1,6 @@ +subscription UserChildCollectionSorted { + userChildCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRootCollectionsSorted.graphql b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRootCollectionsSorted.graphql new file mode 100644 index 00000000..ed77dd45 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/api/subscriptions/UserRootCollectionsSorted.graphql @@ -0,0 +1,6 @@ +subscription UserRootCollectionsSorted { + userRootCollectionsSorted { + parentCollectionID + sortOption + } +} diff --git a/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts b/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts index dd776ee2..3581daf4 100644 --- a/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts +++ b/packages/hoppscotch-selfhost-web/src/lib/sync/index.ts @@ -38,16 +38,15 @@ export const getSyncInitFunction = >( 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) => { diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/api.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/api.ts index 66ac4ab9..359f7620 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/api.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/api.ts @@ -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: {} }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts index a2f43d2a..ce2f0637 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts @@ -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() diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts index a749392d..83249bc6 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts @@ -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, diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/api.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/api.ts index 66ac4ab9..359f7620 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/api.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/api.ts @@ -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: {} }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts index abb497e9..348eb46b 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts @@ -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() diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts index afe55fff..49669ab8 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts @@ -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,