feat: API Documentation (#5499)

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Nivedin 2025-11-25 11:26:57 +05:30 committed by GitHub
parent ce026d5cef
commit e63bfe3723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 9889 additions and 128 deletions

View file

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "PublishedDocs" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"collectionID" TEXT NOT NULL,
"creatorUid" TEXT NOT NULL,
"version" TEXT NOT NULL,
"autoSync" BOOLEAN NOT NULL,
"documentTree" JSONB,
"workspaceType" "WorkspaceType" NOT NULL,
"workspaceID" TEXT NOT NULL,
"metadata" JSONB,
"createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "PublishedDocs_pkey" PRIMARY KEY ("id")
);

View file

@ -296,6 +296,21 @@ model MockServerActivity {
@@index([mockServerID]) @@index([mockServerID])
} }
model PublishedDocs {
id String @id @default(cuid())
title String
collectionID String
creatorUid String
version String
autoSync Boolean
documentTree Json? // Optional if autoSync is true
workspaceType WorkspaceType
workspaceID String
metadata Json?
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
}
enum WorkspaceType { enum WorkspaceType {
USER USER
TEAM TEAM

View file

@ -37,6 +37,7 @@ import { PrismaModule } from './prisma/prisma.module';
import { PubSubModule } from './pubsub/pubsub.module'; import { PubSubModule } from './pubsub/pubsub.module';
import { SortModule } from './orchestration/sort/sort.module'; import { SortModule } from './orchestration/sort/sort.module';
import { MockServerModule } from './mock-server/mock-server.module'; import { MockServerModule } from './mock-server/mock-server.module';
import { PublishedDocsModule } from './published-docs/published-docs.module';
@Module({ @Module({
imports: [ imports: [
@ -126,6 +127,7 @@ import { MockServerModule } from './mock-server/mock-server.module';
InfraTokenModule, InfraTokenModule,
SortModule, SortModule,
MockServerModule, MockServerModule,
PublishedDocsModule,
], ],
providers: [ providers: [
GQLComplexityPlugin, GQLComplexityPlugin,

View file

@ -928,3 +928,34 @@ export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found';
*/ */
export const MOCK_SERVER_LOG_DELETION_FAILED = export const MOCK_SERVER_LOG_DELETION_FAILED =
'mock_server/log_deletion_failed'; 'mock_server/log_deletion_failed';
/**
* Published Docs invalid collection
* (PublishedDocsService)
*/
export const PUBLISHED_DOCS_INVALID_COLLECTION =
'published_docs/invalid_collection';
/**
* Published Docs creation failed
* (PublishedDocsService)
*/
export const PUBLISHED_DOCS_CREATION_FAILED = 'published_docs/creation_failed';
/**
* Published Docs update failed
* (PublishedDocsService)
*/
export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed';
/**
* Published Docs deletion failed
* (PublishedDocsService)
*/
export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_failed';
/**
* Published Docs not found
* (PublishedDocsService)
*/
export const PUBLISHED_DOCS_NOT_FOUND = 'published_docs/not_found';

View file

@ -33,6 +33,7 @@ import { InfraTokenResolver } from './infra-token/infra-token.resolver';
import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver'; import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver';
import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver'; import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver';
import { MockServerResolver } from './mock-server/mock-server.resolver'; import { MockServerResolver } from './mock-server/mock-server.resolver';
import { PublishedDocsResolver } from './published-docs/published-docs.resolver';
/** /**
* All the resolvers present in the application. * All the resolvers present in the application.
@ -68,6 +69,7 @@ const RESOLVERS = [
SortUserCollectionResolver, SortUserCollectionResolver,
SortTeamCollectionResolver, SortTeamCollectionResolver,
MockServerResolver, MockServerResolver,
PublishedDocsResolver,
]; ];
/** /**

View file

@ -26,14 +26,10 @@ import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamAccessRole } from 'src/team/team.model'; import { TeamAccessRole } from 'src/team/team.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { MockServerAnalyticsService } from './mock-server-analytics.service';
@Resolver(() => MockServer) @Resolver(() => MockServer)
export class MockServerResolver { export class MockServerResolver {
constructor( constructor(private readonly mockServerService: MockServerService) {}
private readonly mockServerService: MockServerService,
private readonly mockServerAnalyticsService: MockServerAnalyticsService,
) {}
// Resolve Fields // Resolve Fields

View file

@ -0,0 +1,81 @@
import { InputType, Field } from '@nestjs/graphql';
import { WorkspaceType } from 'src/types/WorkspaceTypes';
@InputType()
export class CreatePublishedDocsArgs {
@Field({
name: 'title',
description: 'Title of the published document',
})
title: string;
@Field({
name: 'version',
description: 'Version of the published document',
})
version: string;
@Field({
name: 'autoSync',
description:
'Whether the published document should auto-sync with the source',
})
autoSync: boolean;
@Field(() => WorkspaceType, {
name: 'workspaceType',
description: 'Type of the workspace (e.g., personal, team)',
})
workspaceType: WorkspaceType;
@Field({
name: 'workspaceID',
description: 'ID of the workspace',
})
workspaceID: string;
@Field({
name: 'collectionID',
description:
'ID of the source (personal/team) collection from which to publish',
})
collectionID: string;
@Field({
name: 'metadata',
description: 'Metadata associated with the published document',
})
metadata: string;
}
@InputType()
export class UpdatePublishedDocsArgs {
@Field({
name: 'title',
description: 'Title of the published document',
nullable: true,
})
title?: string;
@Field({
name: 'version',
description: 'Version of the published document',
nullable: true,
})
version?: string;
@Field({
name: 'autoSync',
description:
'Whether the published document should auto-sync with the source',
nullable: true,
})
autoSync?: boolean;
@Field({
name: 'metadata',
description: 'Metadata associated with the published document',
nullable: true,
})
metadata?: string;
}

View file

@ -0,0 +1,52 @@
import {
Controller,
Get,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PublishedDocsService } from './published-docs.service';
import { GetPublishedDocsQueryDto } from './published-docs.dto';
import * as E from 'fp-ts/Either';
import { throwHTTPErr } from 'src/utils';
import { PublishedDocs } from './published-docs.model';
@ApiTags('Published Docs')
@Controller({ version: '1', path: 'published-docs' })
export class PublishedDocsController {
constructor(private readonly publishedDocsService: PublishedDocsService) {}
@Get(':docId')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get published documentation',
description:
'Returns published collection documentation in API-doc JSON format for unauthenticated users',
})
@ApiResponse({
status: 200,
description: 'Successfully retrieved published documentation',
type: () => PublishedDocs,
})
@ApiResponse({
status: 404,
description: 'Published documentation not found',
})
async getPublishedDocs(
@Param('docId') docId: string,
@Query() query: GetPublishedDocsQueryDto,
) {
const result = await this.publishedDocsService.getPublishedDocByIDPublic(
docId,
query,
);
if (E.isLeft(result)) {
throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND });
}
return result.right;
}
}

View file

@ -0,0 +1,19 @@
import { IsEnum, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export enum TreeLevel {
FULL = 'full',
FIRST_LEVEL = 'first_level',
}
export class GetPublishedDocsQueryDto {
@ApiPropertyOptional({
description: 'Specifies whether to return full tree or only first level',
enum: TreeLevel,
default: TreeLevel.FULL,
required: false,
})
@IsOptional()
@IsEnum(TreeLevel)
tree?: TreeLevel = TreeLevel.FULL;
}

View file

@ -0,0 +1,118 @@
import { ObjectType, Field, ID } from '@nestjs/graphql';
import { ApiProperty } from '@nestjs/swagger';
@ObjectType()
export class PublishedDocs {
@Field(() => ID, {
description: 'ID of the published API documentation',
})
@ApiProperty({
description: 'ID of the published API documentation',
example: 'doc_12345',
})
id: string;
@Field({ description: 'Title of the published API documentation' })
@ApiProperty({
description: 'Title of the published API documentation',
example: 'My API Documentation',
})
title: string;
@Field({
description: 'URL where the published API documentation can be accessed',
})
@ApiProperty({
description: 'URL where the published API documentation can be accessed',
example: 'https://docs.example.com/api',
})
url: string;
@Field({ description: 'Version of the published API documentation' })
@ApiProperty({
description: 'Version of the published API documentation',
example: '1.0.0',
})
version: string;
@Field({ description: 'Indicates if the documentation is set to auto-sync' })
@ApiProperty({
description: 'Indicates if the documentation is set to auto-sync',
example: true,
})
autoSync: boolean;
@Field({
description: 'Document tree structure associated with the documentation',
})
@ApiProperty({
description: 'Document tree structure associated with the documentation',
example:
'{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}',
})
documentTree: string;
@Field({
description:
'Type of workspace associated with the published documentation',
})
@ApiProperty({
description:
'Type of workspace associated with the published documentation',
example: 'team',
})
workspaceType: string;
@Field({
description:
'Workspace ID (of team/user ID) associated with the published documentation',
})
@ApiProperty({
description:
'Workspace ID (of team/user ID) associated with the published documentation',
example: 'workspace_12345',
})
workspaceID: string;
@Field({ description: 'Metadata of the documentation' })
@ApiProperty({
description: 'Metadata of the documentation',
example: '{"author": "John Doe", "tags": ["api", "rest"]}',
})
metadata: string;
@Field({ description: 'Timestamp when the documentation was created' })
@ApiProperty({
description: 'Timestamp when the documentation was created',
example: '2024-01-01T00:00:00.000Z',
})
createdOn: Date;
@Field({ description: 'Timestamp when the documentation was last updated' })
@ApiProperty({
description: 'Timestamp when the documentation was last updated',
example: '2024-01-15T12:30:00.000Z',
})
updatedOn: Date;
}
@ObjectType()
export class PublishedDocsCollection {
@Field(() => ID, {
description: 'ID of the collection',
})
@ApiProperty({
description: 'ID of the collection',
example: 'collection_12345',
})
id: string;
@Field({
description: 'Title of the collection',
})
@ApiProperty({
description: 'Title of the collection',
example: 'My API Collection',
})
title: string;
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PublishedDocsResolver } from './published-docs.resolver';
import { PublishedDocsService } from './published-docs.service';
import { TeamModule } from 'src/team/team.module';
import { PublishedDocsController } from './published-docs.controller';
import { UserCollectionModule } from 'src/user-collection/user-collection.module';
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
@Module({
imports: [UserCollectionModule, TeamModule, TeamCollectionModule],
controllers: [PublishedDocsController],
providers: [PublishedDocsResolver, PublishedDocsService],
})
export class PublishedDocsModule {}

View file

@ -0,0 +1,205 @@
import { UseGuards } from '@nestjs/common';
import {
Args,
ID,
Mutation,
Parent,
ResolveField,
Resolver,
Query,
} from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { PublishedDocs, PublishedDocsCollection } from './published-docs.model';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import {
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
} from './input-type.args';
import { User } from 'src/user/user.model';
import { PublishedDocsService } from './published-docs.service';
import * as E from 'fp-ts/Either';
import { throwErr } from 'src/utils';
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamAccessRole } from 'src/team/team.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => PublishedDocs)
export class PublishedDocsResolver {
constructor(private readonly publishedDocsService: PublishedDocsService) {}
// Resolve Fields
@ResolveField(() => User, {
description: 'Returns the creator of the published document',
})
async creator(@Parent() publishedDocs: PublishedDocs): Promise<User> {
const creator = await this.publishedDocsService.getPublishedDocsCreator(
publishedDocs.id,
);
if (E.isLeft(creator)) throwErr(creator.left);
return {
...creator.right,
currentGQLSession: JSON.stringify(creator.right.currentGQLSession),
currentRESTSession: JSON.stringify(creator.right.currentRESTSession),
};
}
@ResolveField(() => PublishedDocsCollection, {
description: 'Returns the collection of the published document',
})
async collection(
@Parent() publishedDocs: PublishedDocs,
): Promise<PublishedDocsCollection | null> {
const collection =
await this.publishedDocsService.getPublishedDocsCollection(
publishedDocs.id,
);
if (E.isLeft(collection)) throwErr(collection.left);
return collection.right;
}
// Queries
@Query(() => PublishedDocs, {
description: 'Get a published document by ID',
})
@UseGuards(GqlAuthGuard)
async publishedDoc(
@GqlUser() user: User,
@Args({
name: 'id',
type: () => ID,
description: 'Id of the published document to fetch',
})
id: string,
) {
const doc = await this.publishedDocsService.getPublishedDocByID(id, user);
if (E.isLeft(doc)) throwErr(doc.left);
return doc.right;
}
@Query(() => [PublishedDocs], {
description: 'Get all published documents of a user',
})
@UseGuards(GqlAuthGuard)
async userPublishedDocsList(
@GqlUser() user: User,
@Args() args: OffsetPaginationArgs,
) {
const docs = await this.publishedDocsService.getAllUserPublishedDocs(
user.uid,
args,
);
return docs;
}
@Query(() => [PublishedDocs], {
description: 'Get all published documents',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(
TeamAccessRole.VIEWER,
TeamAccessRole.EDITOR,
TeamAccessRole.OWNER,
)
async teamPublishedDocsList(
@Args({
name: 'teamID',
type: () => ID,
description: 'Id of the team to add to',
})
teamID: string,
@Args({
name: 'collectionID',
type: () => ID,
description: 'Id of the collection to add to',
})
collectionID: string,
@Args() args: OffsetPaginationArgs,
) {
const docs = await this.publishedDocsService.getAllTeamPublishedDocs(
teamID,
collectionID,
args,
);
return docs;
}
// Mutations
@Mutation(() => PublishedDocs, {
description: 'Create a new published document',
})
@UseGuards(GqlAuthGuard)
async createPublishedDoc(
@GqlUser() user: User,
@Args({
name: 'args',
type: () => CreatePublishedDocsArgs,
description: 'Arguments for creating a published document',
})
args: CreatePublishedDocsArgs,
) {
const newDoc = await this.publishedDocsService.createPublishedDoc(
args,
user,
);
if (E.isLeft(newDoc)) throwErr(newDoc.left);
return newDoc.right;
}
@Mutation(() => PublishedDocs, {
description: 'Update an existing published document',
})
@UseGuards(GqlAuthGuard)
async updatePublishedDoc(
@GqlUser() user: User,
@Args({
name: 'id',
description: 'ID of the published document to update',
type: () => ID,
})
id: string,
@Args({
name: 'args',
type: () => UpdatePublishedDocsArgs,
description: 'Arguments for updating a published document',
})
args: UpdatePublishedDocsArgs,
) {
const updatedDoc = await this.publishedDocsService.updatePublishedDoc(
id,
args,
user,
);
if (E.isLeft(updatedDoc)) throwErr(updatedDoc.left);
return updatedDoc.right;
}
@Mutation(() => Boolean, {
description: 'Delete a published document by ID',
})
@UseGuards(GqlAuthGuard)
async deletePublishedDoc(
@GqlUser() user: User,
@Args({
name: 'id',
description: 'ID of the published document to delete',
type: () => ID,
})
id: string,
) {
const result = await this.publishedDocsService.deletePublishedDoc(id, user);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
}

View file

@ -0,0 +1,904 @@
import { PublishedDocs as DBPublishedDocs } from 'src/generated/prisma/client';
import { mockDeep, mockReset } from 'jest-mock-extended';
import {
PUBLISHED_DOCS_CREATION_FAILED,
PUBLISHED_DOCS_DELETION_FAILED,
PUBLISHED_DOCS_INVALID_COLLECTION,
PUBLISHED_DOCS_NOT_FOUND,
PUBLISHED_DOCS_UPDATE_FAILED,
TEAM_INVALID_ID,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { PrismaService } from 'src/prisma/prisma.service';
import { User } from 'src/user/user.model';
import { WorkspaceType } from 'src/types/WorkspaceTypes';
import { PublishedDocsService } from './published-docs.service';
import { PublishedDocs } from './published-docs.model';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import {
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
} from './input-type.args';
import { TeamAccessRole } from 'src/team/team.model';
import { TreeLevel } from './published-docs.dto';
import { ConfigService } from '@nestjs/config';
import { right } from 'fp-ts/lib/EitherT';
const mockPrisma = mockDeep<PrismaService>();
const mockUserCollectionService = mockDeep<UserCollectionService>();
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockConfigService = mockDeep<ConfigService>();
const publishedDocsService = new PublishedDocsService(
mockPrisma,
mockUserCollectionService,
mockTeamCollectionService,
mockConfigService,
);
const currentTime = new Date();
const user: User = {
uid: '123344',
email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
lastLoggedOn: currentTime,
lastActiveOn: currentTime,
createdOn: currentTime,
currentGQLSession: JSON.stringify({}),
currentRESTSession: JSON.stringify({}),
};
const userPublishedDoc: DBPublishedDocs = {
id: 'pub_doc_1',
title: 'User API Documentation',
version: '1.0.0',
autoSync: true,
documentTree: {},
workspaceType: WorkspaceType.USER,
workspaceID: user.uid,
collectionID: 'collection_1',
creatorUid: user.uid,
metadata: {},
createdOn: currentTime,
updatedOn: currentTime,
};
const userPublishedDocCasted: PublishedDocs = {
id: userPublishedDoc.id,
title: userPublishedDoc.title,
version: userPublishedDoc.version,
autoSync: userPublishedDoc.autoSync,
documentTree: JSON.stringify(userPublishedDoc.documentTree),
workspaceType: userPublishedDoc.workspaceType,
workspaceID: userPublishedDoc.workspaceID,
metadata: JSON.stringify(userPublishedDoc.metadata),
createdOn: userPublishedDoc.createdOn,
updatedOn: userPublishedDoc.updatedOn,
url: `${mockConfigService.get('VITE_BASE_URL')}/view/${userPublishedDoc.id}/${userPublishedDoc.version}`,
};
const teamPublishedDoc: DBPublishedDocs = {
id: 'pub_doc_2',
title: 'Team API Documentation',
version: '1.0.0',
autoSync: true,
documentTree: {},
workspaceType: WorkspaceType.TEAM,
workspaceID: 'team_1',
collectionID: 'team_collection_1',
creatorUid: user.uid,
metadata: {},
createdOn: currentTime,
updatedOn: currentTime,
};
const teamPublishedDocCasted: PublishedDocs = {
id: teamPublishedDoc.id,
title: teamPublishedDoc.title,
version: teamPublishedDoc.version,
autoSync: teamPublishedDoc.autoSync,
documentTree: JSON.stringify(teamPublishedDoc.documentTree),
workspaceType: teamPublishedDoc.workspaceType,
workspaceID: teamPublishedDoc.workspaceID,
metadata: JSON.stringify(teamPublishedDoc.metadata),
createdOn: teamPublishedDoc.createdOn,
updatedOn: teamPublishedDoc.updatedOn,
url: `${mockConfigService.get('VITE_BASE_URL')}/view/${teamPublishedDoc.id}/${teamPublishedDoc.version}`,
};
beforeEach(() => {
mockReset(mockPrisma);
});
describe('getPublishedDocByID', () => {
test('should return a published document with valid ID and user access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
const result = await publishedDocsService.getPublishedDocByID(
userPublishedDoc.id,
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right).toMatchObject(userPublishedDocCasted);
}
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.getPublishedDocByID(
'invalid_id',
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...userPublishedDoc,
creatorUid: 'different_user',
});
const result = await publishedDocsService.getPublishedDocByID(
userPublishedDoc.id,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should return team published document when user has team access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
const result = await publishedDocsService.getPublishedDocByID(
teamPublishedDoc.id,
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right).toMatchObject(teamPublishedDocCasted);
}
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have team access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce(null);
const result = await publishedDocsService.getPublishedDocByID(
teamPublishedDoc.id,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
});
describe('getAllUserPublishedDocs', () => {
test('should return a list of user published documents with pagination', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
{ skip: 0, take: 10 },
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject(userPublishedDocCasted);
});
test('should return an empty array when no documents found', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
{ skip: 0, take: 10 },
);
expect(result).toEqual([]);
});
test('should return paginated results correctly', async () => {
const docs = [userPublishedDoc, { ...userPublishedDoc, id: 'pub_doc_3' }];
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([docs[0]]);
const result = await publishedDocsService.getAllUserPublishedDocs(
user.uid,
{ skip: 0, take: 1 },
);
expect(result).toHaveLength(1);
});
});
describe('getAllTeamPublishedDocs', () => {
test('should return a list of team published documents with pagination', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]);
const result = await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
'team_collection_1',
{ skip: 0, take: 10 },
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject(teamPublishedDocCasted);
});
test('should return an empty array when no team documents found', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]);
const result = await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
'team_collection_1',
{ skip: 0, take: 10 },
);
expect(result).toEqual([]);
});
test('should filter by teamID and collectionID correctly', async () => {
mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]);
await publishedDocsService.getAllTeamPublishedDocs(
'team_1',
'team_collection_1',
{ skip: 0, take: 10 },
);
expect(mockPrisma.publishedDocs.findMany).toHaveBeenCalledWith({
where: {
workspaceType: WorkspaceType.TEAM,
workspaceID: 'team_1',
collectionID: 'team_collection_1',
},
skip: 0,
take: 10,
orderBy: {
createdOn: 'desc',
},
});
});
});
describe('createPublishedDoc', () => {
const createArgs: CreatePublishedDocsArgs = {
title: 'New API Documentation',
version: '1.0.0',
autoSync: true,
workspaceType: WorkspaceType.USER,
workspaceID: user.uid,
collectionID: 'collection_1',
metadata: '{}',
};
test('should successfully create a user published document with valid inputs', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValueOnce({
id: 'collection_1',
userUid: user.uid,
} as any);
mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc);
const result = await publishedDocsService.createPublishedDoc(
createArgs,
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right).toMatchObject(userPublishedDocCasted);
}
});
test('should successfully create a team published document with valid inputs', async () => {
const teamArgs: CreatePublishedDocsArgs = {
...createArgs,
workspaceType: WorkspaceType.TEAM,
workspaceID: 'team_1',
collectionID: 'team_collection_1',
};
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({
id: 'team_collection_1',
teamID: 'team_1',
} as any);
mockPrisma.publishedDocs.create.mockResolvedValueOnce(teamPublishedDoc);
const result = await publishedDocsService.createPublishedDoc(
teamArgs,
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right).toMatchObject(teamPublishedDocCasted);
}
});
test('should throw TEAM_INVALID_ID when team ID is invalid', async () => {
const teamArgs: CreatePublishedDocsArgs = {
...createArgs,
workspaceType: WorkspaceType.TEAM,
workspaceID: '',
};
const result = await publishedDocsService.createPublishedDoc(
teamArgs,
user,
);
expect(result).toEqualLeft(TEAM_INVALID_ID);
});
test('should throw TEAM_INVALID_ID when user does not have team access', async () => {
const teamArgs: CreatePublishedDocsArgs = {
...createArgs,
workspaceType: WorkspaceType.TEAM,
workspaceID: 'team_1',
};
mockPrisma.team.findFirst.mockResolvedValueOnce(null);
const result = await publishedDocsService.createPublishedDoc(
teamArgs,
user,
);
expect(result).toEqualLeft(TEAM_INVALID_ID);
});
test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is invalid', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.createPublishedDoc(
createArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION);
});
test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is invalid', async () => {
const teamArgs: CreatePublishedDocsArgs = {
...createArgs,
workspaceType: WorkspaceType.TEAM,
workspaceID: 'team_1',
collectionID: 'invalid_collection',
};
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.createPublishedDoc(
teamArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION);
});
test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when collection does not belong to user', async () => {
// When Prisma queries with where: { id: 'collection_1', userUid: user.uid }
// and the collection doesn't belong to the user, it returns null
mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.createPublishedDoc(
createArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION);
});
test('should throw error when metadata is invalid JSON', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValueOnce({
id: 'collection_1',
userUid: user.uid,
} as any);
const result = await publishedDocsService.createPublishedDoc(
{ ...createArgs, metadata: '{invalid' },
user,
);
expect(E.isLeft(result)).toBe(true);
});
test('should throw PUBLISHED_DOCS_CREATION_FAILED on database error', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValueOnce({
id: 'collection_1',
userUid: user.uid,
} as any);
mockPrisma.publishedDocs.create.mockRejectedValueOnce(
new Error('Database error'),
);
const result = await publishedDocsService.createPublishedDoc(
createArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED);
});
});
describe('updatePublishedDoc', () => {
const updateArgs: UpdatePublishedDocsArgs = {
title: 'Updated API Documentation',
version: '2.0.0',
autoSync: false,
metadata: '{"key": "value"}',
};
test('should successfully update a published document with valid inputs', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.publishedDocs.update.mockResolvedValueOnce({
...userPublishedDoc,
title: updateArgs.title,
version: updateArgs.version,
autoSync: updateArgs.autoSync,
});
const result = await publishedDocsService.updatePublishedDoc(
userPublishedDoc.id,
updateArgs,
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right.title).toBe(updateArgs.title);
expect(result.right.version).toBe(updateArgs.version);
expect(result.right.autoSync).toBe(updateArgs.autoSync);
}
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.updatePublishedDoc(
'invalid_id',
updateArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user does not have access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...userPublishedDoc,
creatorUid: 'different_user',
});
const result = await publishedDocsService.updatePublishedDoc(
userPublishedDoc.id,
updateArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED);
});
test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user is not OWNER or EDITOR of team', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce(null);
const result = await publishedDocsService.updatePublishedDoc(
teamPublishedDoc.id,
updateArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED);
});
test('should successfully update team published document when user has OWNER role', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.publishedDocs.update.mockResolvedValueOnce({
...teamPublishedDoc,
title: updateArgs.title,
});
const result = await publishedDocsService.updatePublishedDoc(
teamPublishedDoc.id,
updateArgs,
user,
);
expect(E.isRight(result)).toBe(true);
});
test('should successfully update team published document when user has EDITOR role', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.publishedDocs.update.mockResolvedValueOnce({
...teamPublishedDoc,
title: updateArgs.title,
});
const result = await publishedDocsService.updatePublishedDoc(
teamPublishedDoc.id,
updateArgs,
user,
);
expect(E.isRight(result)).toBe(true);
});
test('should throw error when metadata is invalid JSON', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
const result = await publishedDocsService.updatePublishedDoc(
userPublishedDoc.id,
{ ...updateArgs, metadata: '{invalid' },
user,
);
expect(E.isLeft(result)).toBe(true);
});
test('should update only provided fields', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.publishedDocs.update.mockResolvedValueOnce({
...userPublishedDoc,
title: 'Only Title Updated',
});
const result = await publishedDocsService.updatePublishedDoc(
userPublishedDoc.id,
{ title: 'Only Title Updated' },
user,
);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right.title).toBe('Only Title Updated');
}
});
test('should throw PUBLISHED_DOCS_UPDATE_FAILED on database error', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.publishedDocs.update.mockRejectedValueOnce(
new Error('Database error'),
);
const result = await publishedDocsService.updatePublishedDoc(
userPublishedDoc.id,
updateArgs,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED);
});
});
describe('deletePublishedDoc', () => {
test('should successfully delete a user published document', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.publishedDocs.delete.mockResolvedValueOnce(userPublishedDoc);
const result = await publishedDocsService.deletePublishedDoc(
userPublishedDoc.id,
user,
);
expect(result).toEqualRight(true);
});
test('should successfully delete a team published document when user has OWNER role', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc);
const result = await publishedDocsService.deletePublishedDoc(
teamPublishedDoc.id,
user,
);
expect(result).toEqualRight(true);
});
test('should successfully delete a team published document when user has EDITOR role', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc);
const result = await publishedDocsService.deletePublishedDoc(
teamPublishedDoc.id,
user,
);
expect(result).toEqualRight(true);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.deletePublishedDoc(
'invalid_id',
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should throw PUBLISHED_DOCS_DELETION_FAILED when user does not have access', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...userPublishedDoc,
creatorUid: 'different_user',
});
const result = await publishedDocsService.deletePublishedDoc(
userPublishedDoc.id,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED);
});
test('should throw PUBLISHED_DOCS_DELETION_FAILED when user is not OWNER or EDITOR of team', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.team.findFirst.mockResolvedValueOnce(null);
const result = await publishedDocsService.deletePublishedDoc(
teamPublishedDoc.id,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED);
});
test('should throw PUBLISHED_DOCS_DELETION_FAILED on database error', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.publishedDocs.delete.mockRejectedValueOnce(
new Error('Database error'),
);
const result = await publishedDocsService.deletePublishedDoc(
userPublishedDoc.id,
user,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED);
});
});
describe('getPublishedDocsCreator', () => {
test('should return the creator of a published document', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.user.findUnique.mockResolvedValueOnce(user as any);
const result = await publishedDocsService.getPublishedDocsCreator(
userPublishedDoc.id,
);
expect(result).toEqualRight(user);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result =
await publishedDocsService.getPublishedDocsCreator('invalid_id');
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
});
describe('getPublishedDocsCollection', () => {
test('should return user collection for user workspace published document', async () => {
const userCollection = {
id: 'collection_1',
userUid: user.uid,
title: 'My Collection',
};
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.userCollection.findUnique.mockResolvedValueOnce(
userCollection as any,
);
const result = await publishedDocsService.getPublishedDocsCollection(
userPublishedDoc.id,
);
expect(result).toEqualRight(userCollection);
});
test('should return team collection for team workspace published document', async () => {
const teamCollection = {
id: 'team_collection_1',
teamID: 'team_1',
title: 'Team Collection',
};
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(
teamCollection as any,
);
const result = await publishedDocsService.getPublishedDocsCollection(
teamPublishedDoc.id,
);
expect(result).toEqualRight(teamCollection);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result =
await publishedDocsService.getPublishedDocsCollection('invalid_id');
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is not found', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc);
mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.getPublishedDocsCollection(
userPublishedDoc.id,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION);
});
test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is not found', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc);
mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.getPublishedDocsCollection(
teamPublishedDoc.id,
);
expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION);
});
});
describe('checkPublishedDocsAccess', () => {
test('should return true for user workspace when user is the creator', async () => {
const result = await publishedDocsService.checkPublishedDocsAccess(
userPublishedDoc,
user.uid,
);
expect(result).toBe(true);
});
test('should return false for user workspace when user is not the creator', async () => {
const result = await publishedDocsService.checkPublishedDocsAccess(
userPublishedDoc,
'different_user',
);
expect(result).toBe(false);
});
test('should return true for team workspace when user has required role', async () => {
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
const result = await publishedDocsService.checkPublishedDocsAccess(
teamPublishedDoc,
user.uid,
[TeamAccessRole.OWNER],
);
expect(result).toBe(true);
});
test('should return false for team workspace when user does not have required role', async () => {
mockPrisma.team.findFirst.mockResolvedValueOnce(null);
const result = await publishedDocsService.checkPublishedDocsAccess(
teamPublishedDoc,
user.uid,
[TeamAccessRole.OWNER],
);
expect(result).toBe(false);
});
test('should check for VIEWER role by default', async () => {
mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any);
const result = await publishedDocsService.checkPublishedDocsAccess(
teamPublishedDoc,
user.uid,
);
expect(result).toBe(true);
expect(mockPrisma.team.findFirst).toHaveBeenCalledWith({
where: {
id: 'team_1',
members: {
some: {
userUid: user.uid,
role: {
in: [
TeamAccessRole.OWNER,
TeamAccessRole.EDITOR,
TeamAccessRole.VIEWER,
],
},
},
},
},
});
});
});
describe('getPublishedDocByIDPublic', () => {
test('should return collection data when autoSync is enabled for user workspace', async () => {
const collectionData = {
id: 'collection_1',
name: 'Test Collection',
folders: [],
requests: [],
};
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...userPublishedDoc,
autoSync: true,
});
mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce(
E.right(collectionData as any),
);
const result = await publishedDocsService.getPublishedDocByIDPublic(
userPublishedDoc.id,
{ tree: TreeLevel.FULL },
);
expect(result).toMatchObject(
E.right({
...userPublishedDocCasted,
documentTree: JSON.stringify(collectionData),
}),
);
});
test('should return collection data when autoSync is enabled for team workspace', async () => {
const collectionData = {
id: 'team_collection_1',
name: 'Team Test Collection',
folders: [],
requests: [],
};
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...teamPublishedDoc,
autoSync: true,
});
mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce(
E.right(collectionData as any),
);
const result = await publishedDocsService.getPublishedDocByIDPublic(
teamPublishedDoc.id,
{ tree: TreeLevel.FULL },
);
expect(result).toMatchObject(
E.right({
...teamPublishedDocCasted,
documentTree: JSON.stringify(collectionData),
}),
);
});
test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null);
const result = await publishedDocsService.getPublishedDocByIDPublic(
'invalid_id',
{ tree: TreeLevel.FULL },
);
expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND);
});
test('should call exportUserCollectionToJSONObject with correct parameters', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...userPublishedDoc,
autoSync: true,
});
mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce(
E.right({} as any),
);
await publishedDocsService.getPublishedDocByIDPublic(userPublishedDoc.id, {
tree: TreeLevel.FULL,
} as any);
expect(
mockUserCollectionService.exportUserCollectionToJSONObject,
).toHaveBeenCalledWith(user.uid, 'collection_1', true);
});
test('should call exportCollectionToJSONObject with correct parameters', async () => {
mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({
...teamPublishedDoc,
autoSync: true,
});
mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce(
E.right({} as any),
);
await publishedDocsService.getPublishedDocByIDPublic(teamPublishedDoc.id, {
tree: TreeLevel.FULL,
});
expect(
mockTeamCollectionService.exportCollectionToJSONObject,
).toHaveBeenCalledWith('team_1', 'team_collection_1', true);
});
});

View file

@ -0,0 +1,431 @@
import { Injectable } from '@nestjs/common';
import {
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
} from './input-type.args';
import { PrismaService } from 'src/prisma/prisma.service';
import { PublishedDocs as DbPublishedDocs } from 'src/generated/prisma/client';
import { TeamAccessRole } from 'src/team/team.model';
import { User } from 'src/user/user.model';
import { WorkspaceType } from 'src/types/WorkspaceTypes';
import {
PUBLISHED_DOCS_CREATION_FAILED,
PUBLISHED_DOCS_DELETION_FAILED,
PUBLISHED_DOCS_INVALID_COLLECTION,
PUBLISHED_DOCS_NOT_FOUND,
PUBLISHED_DOCS_UPDATE_FAILED,
TEAM_INVALID_ID,
USERS_NOT_FOUND,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { PublishedDocs } from './published-docs.model';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { stringToJson } from 'src/utils';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { CollectionFolder } from 'src/types/CollectionFolder';
import { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PublishedDocsService {
constructor(
private readonly prisma: PrismaService,
private readonly userCollectionService: UserCollectionService,
private readonly teamCollectionService: TeamCollectionService,
private readonly configService: ConfigService,
) {}
/**
* Cast database PublishedDocs to GraphQL PublishedDocs
*/
private cast(doc: DbPublishedDocs): PublishedDocs {
return {
...doc,
documentTree: JSON.stringify(doc.documentTree),
metadata: JSON.stringify(doc.metadata),
url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.id}/${doc.version}`,
};
}
/**
* Check if user has access to a team with specific roles
*/
private async checkTeamAccess(
teamId: string,
userUid: string,
requiredRoles: TeamAccessRole[],
): Promise<boolean> {
const team = await this.prisma.team.findFirst({
where: {
id: teamId,
members: {
some: { userUid, role: { in: requiredRoles } },
},
},
});
return team ? true : false;
}
/**
* Validate workspace access permission and existence
*/
private async validateWorkspace(
user: User,
input: { workspaceType: WorkspaceType; workspaceID: string },
) {
if (input.workspaceType === WorkspaceType.TEAM) {
if (!input.workspaceID) return E.left(TEAM_INVALID_ID);
const hasAccess = await this.checkTeamAccess(
input.workspaceID,
user.uid,
[TeamAccessRole.OWNER, TeamAccessRole.EDITOR],
);
if (!hasAccess) return E.left(TEAM_INVALID_ID);
}
return E.right(true);
}
/**
* Validate collection exists and user has access
*/
private async validateCollection(
user: User,
input: {
workspaceType: WorkspaceType;
workspaceID: string;
collectionID: string;
},
) {
if (input.workspaceType === WorkspaceType.TEAM) {
const collection = await this.prisma.teamCollection.findUnique({
where: { id: input.collectionID, teamID: input.workspaceID },
});
return collection
? E.right(collection)
: E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
} else if (input.workspaceType === WorkspaceType.USER) {
const collection = await this.prisma.userCollection.findUnique({
where: { id: input.collectionID, userUid: user.uid },
});
return collection
? E.right(collection)
: E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
}
return E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
}
/**
* Check if user has access to a published docs with specific roles
*/
async checkPublishedDocsAccess(
publishedDocs: DbPublishedDocs,
userUid: string,
requiredRoles: TeamAccessRole[] = [
TeamAccessRole.OWNER,
TeamAccessRole.EDITOR,
TeamAccessRole.VIEWER,
],
): Promise<boolean> {
if (publishedDocs.workspaceType === WorkspaceType.USER) {
return publishedDocs.creatorUid === userUid;
} else if (publishedDocs.workspaceType === WorkspaceType.TEAM) {
return this.checkTeamAccess(
publishedDocs.workspaceID,
userUid,
requiredRoles,
);
}
return false;
}
/**
* (Field resolver)
* Get the creator of a mock server
*/
async getPublishedDocsCreator(id: string) {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
const user = await this.prisma.user.findUnique({
where: { uid: publishedDocs.creatorUid },
});
if (!user) return E.left(USERS_NOT_FOUND);
return E.right(user);
}
/**
* (Field resolver)
* Get the collection of a published document
*/
async getPublishedDocsCollection(id: string) {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
if (publishedDocs.workspaceType === WorkspaceType.USER) {
const collection = await this.prisma.userCollection.findUnique({
where: { id: publishedDocs.collectionID },
});
if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
return E.right(collection);
} else if (publishedDocs.workspaceType === WorkspaceType.TEAM) {
const collection = await this.prisma.teamCollection.findUnique({
where: { id: publishedDocs.collectionID },
});
if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
return E.right(collection);
}
return E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
}
/**
* Get a published document by ID
*/
async getPublishedDocByID(id: string, user: User) {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
// Check access permissions
const hasAccess = await this.checkPublishedDocsAccess(
publishedDocs,
user.uid,
);
if (!hasAccess) return E.left(PUBLISHED_DOCS_NOT_FOUND);
return E.right(this.cast(publishedDocs));
}
/**
* Get a published document by ID for public access (unauthenticated)
* @param id - The ID of the published document
* @param query - Query parameters specifying tree level
*/
async getPublishedDocByIDPublic(
id: string,
query: GetPublishedDocsQueryDto,
): Promise<E.Either<string, PublishedDocs>> {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
// if autoSync is enabled, fetch from the collection directly
if (publishedDocs.autoSync) {
const collectionResult =
publishedDocs.workspaceType === WorkspaceType.USER
? await this.userCollectionService.exportUserCollectionToJSONObject(
publishedDocs.creatorUid,
publishedDocs.collectionID,
query.tree === TreeLevel.FULL,
)
: await this.teamCollectionService.exportCollectionToJSONObject(
publishedDocs.workspaceID,
publishedDocs.collectionID,
query.tree === TreeLevel.FULL,
);
if (E.isLeft(collectionResult)) return E.left(collectionResult.left);
return E.right(
this.cast({
...publishedDocs,
documentTree: JSON.parse(JSON.stringify(collectionResult.right)),
}),
);
}
return E.right(this.cast(publishedDocs));
}
/**
* Get all published documents for a user with pagination
* @param userUid - The UID of the user
* @param args - Pagination arguments
*/
async getAllUserPublishedDocs(userUid: string, args: OffsetPaginationArgs) {
const docs = await this.prisma.publishedDocs.findMany({
where: {
workspaceType: WorkspaceType.USER,
creatorUid: userUid,
},
skip: args.skip,
take: args.take,
orderBy: {
createdOn: 'desc',
},
});
return docs.map((doc) => this.cast(doc));
}
/**
* Get all published documents for a team and collection with pagination
*/
async getAllTeamPublishedDocs(
teamID: string,
collectionID: string,
args: OffsetPaginationArgs,
) {
const docs = await this.prisma.publishedDocs.findMany({
where: {
workspaceType: WorkspaceType.TEAM,
workspaceID: teamID,
collectionID: collectionID,
},
skip: args.skip,
take: args.take,
orderBy: {
createdOn: 'desc',
},
});
return docs.map((doc) => this.cast(doc));
}
/**
* Create a new published document
* @param args - Arguments for creating the published document
* @param user - The user creating the published document
*/
async createPublishedDoc(args: CreatePublishedDocsArgs, user: User) {
try {
// Validate workspace type and ID
const workspaceValidation = await this.validateWorkspace(user, {
workspaceType: args.workspaceType,
workspaceID: args.workspaceID,
});
if (E.isLeft(workspaceValidation)) {
return E.left(workspaceValidation.left);
}
// Validate collection exists and user has access
const collectionValidation = await this.validateCollection(user, {
workspaceType: args.workspaceType,
workspaceID: args.workspaceID,
collectionID: args.collectionID,
});
if (E.isLeft(collectionValidation)) {
return E.left(collectionValidation.left);
}
// Parse metadata
const metadata = stringToJson(args.metadata);
if (E.isLeft(metadata)) return E.left(metadata.left);
// Create published document
const newPublishedDoc = await this.prisma.publishedDocs.create({
data: {
title: args.title,
collectionID: args.collectionID,
creatorUid: user.uid,
version: args.version,
autoSync: args.autoSync,
workspaceType: args.workspaceType,
workspaceID:
args.workspaceType === WorkspaceType.TEAM
? args.workspaceID
: user.uid,
metadata: metadata.right,
},
});
return E.right(this.cast(newPublishedDoc));
} catch (error) {
console.error('Error creating published document:', error);
return E.left(PUBLISHED_DOCS_CREATION_FAILED);
}
}
/**
* Update an existing published document
* @param id - The ID of the published document to update
* @param args - Arguments for updating the published document
* @param user - The user updating the published document
*/
async updatePublishedDoc(
id: string,
args: UpdatePublishedDocsArgs,
user: User,
): Promise<E.Either<string, PublishedDocs>> {
try {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
// Check access permissions based on workspace type (only OWNER and EDITOR can update)
const hasAccess = await this.checkPublishedDocsAccess(
publishedDocs,
user.uid,
[TeamAccessRole.OWNER, TeamAccessRole.EDITOR],
);
if (!hasAccess) return E.left(PUBLISHED_DOCS_UPDATE_FAILED);
//Parse metadata if provided
let metadata: E.Either<string, any>;
if (args.metadata) {
metadata = stringToJson(args.metadata);
if (E.isLeft(metadata)) return E.left(metadata.left);
}
// Update published document
const updatedPublishedDoc = await this.prisma.publishedDocs.update({
where: { id },
data: {
title: args.title,
version: args.version,
autoSync: args.autoSync,
metadata:
metadata && E.isRight(metadata) ? metadata.right : undefined,
},
});
return E.right(this.cast(updatedPublishedDoc));
} catch (error) {
console.error('Error updating published document:', error);
return E.left(PUBLISHED_DOCS_UPDATE_FAILED);
}
}
/** Delete a published document
* @param id - The ID of the published document to delete
* @param user - The user deleting the published document
*/
async deletePublishedDoc(id: string, user: User) {
try {
const publishedDocs = await this.prisma.publishedDocs.findUnique({
where: { id },
});
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
// Check access permissions based on workspace type (only OWNER and EDITOR can update)
const hasAccess = await this.checkPublishedDocsAccess(
publishedDocs,
user.uid,
[TeamAccessRole.OWNER, TeamAccessRole.EDITOR],
);
if (!hasAccess) return E.left(PUBLISHED_DOCS_DELETION_FAILED);
await this.prisma.publishedDocs.delete({
where: { id },
});
return E.right(true);
} catch (error) {
console.error('Error deleting published document:', error);
return E.left(PUBLISHED_DOCS_DELETION_FAILED);
}
}
}

View file

@ -106,31 +106,35 @@ export class TeamCollectionService {
* *
* @param teamID The Team ID * @param teamID The Team ID
* @param collectionID The Collection ID * @param collectionID The Collection ID
* @param withChildren Whether to include child collections and their requests
* @returns A JSON string containing all the contents of a collection * @returns A JSON string containing all the contents of a collection
*/ */
async exportCollectionToJSONObject( async exportCollectionToJSONObject(
teamID: string, teamID: string,
collectionID: string, collectionID: string,
withChildren: boolean = true,
): Promise<E.Right<CollectionFolder> | E.Left<string>> { ): Promise<E.Right<CollectionFolder> | E.Left<string>> {
const collection = await this.getCollection(collectionID); const collection = await this.getCollection(collectionID);
if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID); if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID);
const childrenCollection = await this.prisma.teamCollection.findMany({
where: {
teamID,
parentID: collectionID,
},
orderBy: {
orderIndex: 'asc',
},
});
const childrenCollectionObjects = []; const childrenCollectionObjects = [];
for (const coll of childrenCollection) { if (withChildren) {
const result = await this.exportCollectionToJSONObject(teamID, coll.id); const childrenCollection = await this.prisma.teamCollection.findMany({
if (E.isLeft(result)) return E.left(result.left); where: {
teamID,
parentID: collectionID,
},
orderBy: {
orderIndex: 'asc',
},
});
childrenCollectionObjects.push(result.right); for (const coll of childrenCollection) {
const result = await this.exportCollectionToJSONObject(teamID, coll.id);
if (E.isLeft(result)) return E.left(result.left);
childrenCollectionObjects.push(result.right);
}
} }
const requests = await this.prisma.teamRequest.findMany({ const requests = await this.prisma.teamRequest.findMany({
@ -146,9 +150,17 @@ export class TeamCollectionService {
const data = transformCollectionData(collection.right.data); const data = transformCollectionData(collection.right.data);
const result: CollectionFolder = { const result: CollectionFolder = {
id: collection.right.id,
name: collection.right.title, name: collection.right.title,
folders: childrenCollectionObjects, folders: childrenCollectionObjects,
requests: requests.map((x) => x.request), requests: requests.map((x) => {
const requestData =
typeof x.request === 'string' ? JSON.parse(x.request) : x.request;
return {
...requestData,
id: x.id,
};
}),
data, data,
}; };

View file

@ -1,8 +1,34 @@
// This interface defines how data will be received from the app when we are importing Hoppscotch collections import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export interface CollectionFolder {
// This class defines how data will be received from the app when we are importing Hoppscotch collections
export class CollectionFolder {
@ApiPropertyOptional({
description: 'Unique identifier for the collection folder',
example: 'folder_12345',
})
id?: string; id?: string;
@ApiProperty({
description: 'List of subfolders',
type: () => [CollectionFolder],
})
folders: CollectionFolder[]; folders: CollectionFolder[];
@ApiProperty({
description: 'List of requests in the collection folder',
type: [Object],
})
requests: any[]; requests: any[];
@ApiProperty({
description: 'Name of the collection folder',
example: 'My Collection Folder',
})
name: string; name: string;
@ApiPropertyOptional({
description: 'Additional data for the collection folder',
type: String,
})
data?: string; data?: string;
} }

View file

@ -868,37 +868,41 @@ export class UserCollectionService {
* *
* @param userUID The User UID * @param userUID The User UID
* @param collectionID The Collection ID * @param collectionID The Collection ID
* @param withChildren Whether to include child collections and their requests
* @returns A JSON string containing all the contents of a collection * @returns A JSON string containing all the contents of a collection
*/ */
async exportUserCollectionToJSONObject( async exportUserCollectionToJSONObject(
userUID: string, userUID: string,
collectionID: string, collectionID: string,
withChildren: boolean = true,
): Promise<E.Left<string> | E.Right<CollectionFolder>> { ): Promise<E.Left<string> | E.Right<CollectionFolder>> {
// Get Collection details // Get Collection details
const collection = await this.getUserCollection(collectionID); const collection = await this.getUserCollection(collectionID);
if (E.isLeft(collection)) return E.left(collection.left); if (E.isLeft(collection)) return E.left(collection.left);
// Get all child collections whose parentID === collectionID
const childCollectionList = await this.prisma.userCollection.findMany({
where: {
parentID: collectionID,
userUid: userUID,
},
orderBy: {
orderIndex: 'asc',
},
});
// Create a list of child collection and request data ready for export
const childrenCollectionObjects: CollectionFolder[] = []; const childrenCollectionObjects: CollectionFolder[] = [];
for (const coll of childCollectionList) { if (withChildren) {
const result = await this.exportUserCollectionToJSONObject( // Get all child collections whose parentID === collectionID
userUID, const childCollectionList = await this.prisma.userCollection.findMany({
coll.id, where: {
); parentID: collectionID,
if (E.isLeft(result)) return E.left(result.left); userUid: userUID,
},
orderBy: {
orderIndex: 'asc',
},
});
childrenCollectionObjects.push(result.right); // Create a list of child collection and request data ready for export
for (const coll of childCollectionList) {
const result = await this.exportUserCollectionToJSONObject(
userUID,
coll.id,
);
if (E.isLeft(result)) return E.left(result.left);
childrenCollectionObjects.push(result.right);
}
} }
// Fetch all child requests that belong to collectionID // Fetch all child requests that belong to collectionID

View file

@ -79,7 +79,7 @@ export const WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOC
collectionID: "clx1ldkzs005t10f8rp5u60q7", collectionID: "clx1ldkzs005t10f8rp5u60q7",
teamID: "clws3hg58000011o8h07glsb1", teamID: "clws3hg58000011o8h07glsb1",
title: "RequestA", title: "RequestA",
request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{}}`, request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{},"description":""}`,
}, },
], ],
}, },
@ -143,6 +143,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}, },
], ],
requests: [ requests: [
@ -190,6 +191,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [ requests: [
@ -221,6 +223,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}, },
], ],
requests: [ requests: [
@ -245,6 +248,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
preRequestScript: "", preRequestScript: "",
requestVariables: [], requestVariables: [],
responses: {}, responses: {},
description: "",
}, },
], ],
auth: { auth: {
@ -268,6 +272,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
secret: false, secret: false,
}, },
], ],
description: null,
}, },
]; ];
@ -494,7 +499,7 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MO
collectionID: "clx1f86hv000010f8szcfya0t", collectionID: "clx1f86hv000010f8szcfya0t",
teamID: "clws3hg58000011o8h07glsb1", teamID: "clws3hg58000011o8h07glsb1",
title: "root-collection-request", title: "root-collection-request",
request: `{"v":"${RESTReqSchemaVersion}","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"root-collection-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value set at the root collection\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[],"responses":{}}`, request: `{"v":"${RESTReqSchemaVersion}","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"root-collection-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value set at the root collection\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[],"responses":{}, "description": ""}`,
}, },
], ],
}, },
@ -503,17 +508,17 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MO
export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppCollection[] = export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppCollection[] =
[ [
{ {
v: 10, v: CollectionSchemaVersion,
id: "clx1f86hv000010f8szcfya0t", id: "clx1f86hv000010f8szcfya0t",
name: "Multiple child collections with authorization, headers and variables set at each level", name: "Multiple child collections with authorization, headers and variables set at each level",
folders: [ folders: [
{ {
v: 10, v: 11,
id: "clx1fjgah000110f8a5bs68gd", id: "clx1fjgah000110f8a5bs68gd",
name: "folder-1", name: "folder-1",
folders: [ folders: [
{ {
v: 10, v: 11,
id: "clx1fjwmm000410f8l1gkkr1a", id: "clx1fjwmm000410f8l1gkkr1a",
name: "folder-11", name: "folder-11",
folders: [], folders: [],
@ -561,9 +566,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fjyxm000510f8pv90dt43", id: "clx1fjyxm000510f8pv90dt43",
name: "folder-12", name: "folder-12",
folders: [], folders: [],
@ -627,9 +633,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fk1cv000610f88kc3aupy", id: "clx1fk1cv000610f88kc3aupy",
name: "folder-13", name: "folder-13",
folders: [], folders: [],
@ -711,6 +718,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [ requests: [
@ -755,14 +763,15 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fjk9o000210f8j0573pls", id: "clx1fjk9o000210f8j0573pls",
name: "folder-2", name: "folder-2",
folders: [ folders: [
{ {
v: 10, v: 11,
id: "clx1fk516000710f87sfpw6bo", id: "clx1fk516000710f87sfpw6bo",
name: "folder-21", name: "folder-21",
folders: [], folders: [],
@ -808,9 +817,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fk72t000810f8gfwkpi5y", id: "clx1fk72t000810f8gfwkpi5y",
name: "folder-22", name: "folder-22",
folders: [], folders: [],
@ -874,9 +884,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fk95g000910f8bunhaoo8", id: "clx1fk95g000910f8bunhaoo8",
name: "folder-23", name: "folder-23",
folders: [], folders: [],
@ -945,6 +956,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [ requests: [
@ -995,15 +1007,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1fjmlq000310f86o4d3w2o", id: "clx1fjmlq000310f86o4d3w2o",
name: "folder-3", name: "folder-3",
folders: [ folders: [
{ {
v: 10, v: 11,
id: "clx1iwq0p003e10f8u8zg0p85", id: "clx1iwq0p003e10f8u8zg0p85",
name: "folder-31", name: "folder-31",
folders: [], folders: [],
@ -1049,9 +1062,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1izut7003m10f894ip59zg", id: "clx1izut7003m10f894ip59zg",
name: "folder-32", name: "folder-32",
folders: [], folders: [],
@ -1115,9 +1129,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: 10, v: 11,
id: "clx1j2ka9003q10f8cdbzpgpg", id: "clx1j2ka9003q10f8cdbzpgpg",
name: "folder-33", name: "folder-33",
folders: [], folders: [],
@ -1186,6 +1201,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [ requests: [
@ -1249,6 +1265,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [ requests: [
@ -1272,6 +1289,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
preRequestScript: "", preRequestScript: "",
requestVariables: [], requestVariables: [],
responses: {}, responses: {},
description: "",
}, },
], ],
auth: { auth: {
@ -1302,6 +1320,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
secret: false, secret: false,
}, },
], ],
description: null,
}, },
]; ];
@ -1408,6 +1427,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1455,6 +1475,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
secret: false, secret: false,
}, },
], ],
description: null,
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1468,6 +1489,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1495,6 +1517,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
secret: false, secret: false,
}, },
], ],
description: null,
}, },
], ],
requests: [], requests: [],
@ -1504,6 +1527,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}, },
]; ];

View file

@ -178,12 +178,14 @@ export const transformWorkspaceCollections = (
auth?: HoppRESTAuth; auth?: HoppRESTAuth;
headers?: HoppRESTHeaders; headers?: HoppRESTHeaders;
variables: HoppCollectionVariable[]; variables: HoppCollectionVariable[];
description: string | null;
} = data ? JSON.parse(data) : {}; } = data ? JSON.parse(data) : {};
const { const {
auth = { authType: "inherit", authActive: true }, auth = { authType: "inherit", authActive: true },
headers = [], headers = [],
variables = [], variables = [],
description = null,
} = parsedData; } = parsedData;
const transformedAuth = transformAuth(auth); const transformedAuth = transformAuth(auth);
@ -208,6 +210,7 @@ export const transformWorkspaceCollections = (
auth: transformedAuth, auth: transformedAuth,
headers: transformedHeaders, headers: transformedHeaders,
variables: filteredCollectionVariables, variables: filteredCollectionVariables,
description,
}; };
}); });
}; };

View file

@ -406,8 +406,149 @@
"variable": "Variable {count}" "variable": "Variable {count}"
}, },
"documentation": { "documentation": {
"add_description": "Add description for this collection here...",
"add_description_placeholder": "Add description here...",
"add_request_description": "Add description for request here...",
"auth": {
"access_key": "Access Key",
"access_token": "Access Token",
"add_to": "Add to",
"akamai_edgegrid": "Akamai EdgeGrid",
"algorithm": "Algorithm",
"api_key": "API Key",
"app_id": "App ID",
"auth_id": "Auth ID",
"auth_key": "Auth Key",
"auth_url": "Auth URL",
"aws_signature": "AWS Signature",
"basic_auth": "Basic Auth",
"bearer_token": "Bearer Token",
"client_id": "Client ID",
"client_nonce": "Client Nonce",
"client_secret": "Client Secret",
"client_token": "Client Token",
"delegation": "Delegation",
"digest_auth": "Digest Auth",
"extra_data": "Extra Data",
"grant_type": "Grant Type",
"hawk_auth": "HAWK Auth",
"headers_to_sign": "Headers to Sign",
"host": "Host",
"include_payload_hash": "Include Payload Hash",
"jwt_auth": "JWT Auth",
"max_body_size": "Max Body Size",
"no_auth": "No authentication",
"nonce": "Nonce",
"oauth_2": "OAuth 2.0",
"opaque": "Opaque",
"password": "Password",
"payload": "Payload",
"qop": "QOP",
"realm": "Realm",
"region": "Region",
"scope": "Scope",
"secret_key": "Secret Key",
"service_name": "Service Name",
"timestamp": "Timestamp",
"title": "Authentication",
"token_url": "Token URL",
"user": "User",
"username": "Username"
},
"body": {
"content_type": "Content Type",
"no_body": "No body defined",
"title": "Body"
},
"copied_to_clipboard": "Copied to clipboard!",
"curl": {
"click_to_load": "Click to load cURL command",
"copied": "cURL command copied to clipboard!",
"copy_to_clipboard": "Copy to clipboard",
"generating": "Generating cURL command...",
"load": "Load cURL",
"title": "cURL"
},
"description": "Description",
"error_rendering_markdown": "Error rendering markdown:",
"fetching_documentation": "Fetching Documentation...",
"generate": "Generate documentation", "generate": "Generate documentation",
"generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go." "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go.",
"headers": {
"no_headers": "No headers defined",
"title": "Headers"
},
"hide_all_documentation": "Hide All Documentation",
"inherited_from": "Inherited from {name}",
"inherited_with_type": "Inherited {type} from {name}",
"key": "Key",
"loading": "Loading...",
"loading_collection_data": "Loading Collection Data...",
"no": "No",
"no_collection_data": "No collection data available",
"no_documentation_found": "No documentation found for folders or requests",
"no_request_data": "No request data available",
"no_requests_or_folders": "No requests or folders",
"not_set": "Not set",
"open_request_in_new_tab": "Open request in new tab",
"parameters": {
"no_params": "No parameters defined",
"title": "Parameters"
},
"percent_complete": "% complete",
"processing_documentation": "Processing Documentation",
"publish": {
"already_published": "This collection is already published",
"auto_sync": "Auto-sync with collection",
"auto_sync_description": "Automatically update published docs when collection changes",
"button": "Publish",
"copy_url": "Copy URL",
"delete_published_doc": "Are you sure you want to delete the published documentation?",
"delete_success": "Published documentation deleted successfully",
"doc_title": "Title",
"doc_version": "Version",
"edit_published_doc": "Edit Published Doc",
"last_updated": "Last Updated",
"metadata": "Metadata (JSON)",
"publish_error": "Failed to publish documentation",
"publish_success": "Documentation published successfully!",
"published": "Published",
"published_url": "Published URL",
"title": "Publish Documentation",
"update_button": "Update",
"update_error": "Failed to update documentation",
"update_published_docs": "Update Published Docs",
"update_success": "Documentation updated successfully!",
"update_title": "Update Published Documentation",
"url_copied": "URL copied to clipboard!",
"view_published": "View Published Docs",
"view_title": "View Published Documentation"
},
"request_opened_in_new_tab": "Request opened in new tab!",
"response": {
"body": "Response Body",
"copy": "Copy response",
"example_copied": "Response example copied to clipboard!",
"example_copy_failed": "Failed to copy response example",
"headers": "Headers",
"no_examples": "No response examples available",
"title": "Response Examples"
},
"save_error": "Error saving documentation",
"save_success": "Documentation saved successfully",
"saved_items_status": "Saved {success} items. Failed to save {failure} items.",
"show_all_documentation": "Show All Documentation",
"source": "Source",
"title": "Documentation",
"unsaved_changes": "You have {count} unsaved changes. Please save before closing.",
"untitled_collection": "Untitled Collection",
"untitled_request": "Untitled Request",
"value": "Value",
"variables": {
"no_vars": "No variables defined",
"title": "Variables"
},
"yes": "Yes"
}, },
"empty": { "empty": {
"activity_logs": "No activity logs found", "activity_logs": "No activity logs found",
@ -1084,6 +1225,7 @@
"ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...", "ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...",
"experimental_scripting_sandbox": "Experimental scripting sandbox", "experimental_scripting_sandbox": "Experimental scripting sandbox",
"enable_experimental_mock_servers": "Enable Mock Servers", "enable_experimental_mock_servers": "Enable Mock Servers",
"enable_experimental_documentation": "Enable Documentation",
"sync": "Synchronise", "sync": "Synchronise",
"sync_collections": "Collections", "sync_collections": "Collections",
"sync_description": "These settings are synced to cloud.", "sync_description": "These settings are synced to cloud.",

View file

@ -63,6 +63,7 @@
"buffer": "6.0.3", "buffer": "6.0.3",
"cookie-es": "2.0.0", "cookie-es": "2.0.0",
"dioc": "3.0.2", "dioc": "3.0.2",
"dompurify": "3.3.0",
"esprima": "4.0.1", "esprima": "4.0.1",
"events": "3.3.0", "events": "3.3.0",
"fp-ts": "2.16.11", "fp-ts": "2.16.11",
@ -71,6 +72,8 @@
"graphql-language-service-interface": "2.10.2", "graphql-language-service-interface": "2.10.2",
"graphql-tag": "2.12.6", "graphql-tag": "2.12.6",
"hawk": "9.0.2", "hawk": "9.0.2",
"highlight.js": "11.11.1",
"highlightjs-curl": "1.3.0",
"insomnia-importers": "3.6.0", "insomnia-importers": "3.6.0",
"io-ts": "2.2.22", "io-ts": "2.2.22",
"js-md5": "0.8.3", "js-md5": "0.8.3",

View file

@ -50,6 +50,25 @@ declare module 'vue' {
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default'] CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default'] CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
CollectionsCollection: typeof import('./components/collections/Collection.vue')['default'] CollectionsCollection: typeof import('./components/collections/Collection.vue')['default']
CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default']
CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default']
CollectionsDocumentationCollectionStructure: typeof import('./components/collections/documentation/CollectionStructure.vue')['default']
CollectionsDocumentationFolderItem: typeof import('./components/collections/documentation/FolderItem.vue')['default']
CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default']
CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default']
CollectionsDocumentationPreview: typeof import('./components/collections/documentation/Preview.vue')['default']
CollectionsDocumentationPublishDocModal: typeof import('./components/collections/documentation/PublishDocModal.vue')['default']
CollectionsDocumentationRequestItem: typeof import('./components/collections/documentation/RequestItem.vue')['default']
CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default']
CollectionsDocumentationSectionsAuth: typeof import('./components/collections/documentation/sections/Auth.vue')['default']
CollectionsDocumentationSectionsCurlView: typeof import('./components/collections/documentation/sections/CurlView.vue')['default']
CollectionsDocumentationSectionsHeaders: typeof import('./components/collections/documentation/sections/Headers.vue')['default']
CollectionsDocumentationSectionsParameters: typeof import('./components/collections/documentation/sections/Parameters.vue')['default']
CollectionsDocumentationSectionsPreRequestScript: typeof import('./components/collections/documentation/sections/PreRequestScript.vue')['default']
CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default']
CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default']
CollectionsDocumentationSectionsTestScript: typeof import('./components/collections/documentation/sections/TestScript.vue')['default']
CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default'] CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default'] CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default'] CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@ -78,6 +97,9 @@ declare module 'vue' {
ConsoleValue: typeof import('./components/console/Value.vue')['default'] ConsoleValue: typeof import('./components/console/Value.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default'] CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
DocumentationContent: typeof import('./components/documentation/Content.vue')['default']
DocumentationHeader: typeof import('./components/documentation/Header.vue')['default']
DocumentationSkeleton: typeof import('./components/documentation/Skeleton.vue')['default']
Embeds: typeof import('./components/embeds/index.vue')['default'] Embeds: typeof import('./components/embeds/index.vue')['default']
EmbedsHeader: typeof import('./components/embeds/Header.vue')['default'] EmbedsHeader: typeof import('./components/embeds/Header.vue')['default']
EmbedsRequest: typeof import('./components/embeds/Request.vue')['default'] EmbedsRequest: typeof import('./components/embeds/Request.vue')['default']
@ -133,6 +155,7 @@ declare module 'vue' {
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox'] HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip'] HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartIcon: typeof import('@hoppscotch/ui')['HoppSmartIcon']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection'] HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
@ -211,20 +234,26 @@ declare module 'vue' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideCode2: typeof import('~icons/lucide/code2')['default']
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default']
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLightbulb: typeof import('~icons/lucide/lightbulb')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
IconLucideLoader2: typeof import('~icons/lucide/loader2')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideTerminal: typeof import('~icons/lucide/terminal')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default'] IconLucideVerified: typeof import('~icons/lucide/verified')['default']

View file

@ -138,6 +138,7 @@
@keyup.m=" @keyup.m="
isMockServerVisible && mockServerAction?.$el.click() isMockServerVisible && mockServerAction?.$el.click()
" "
@keyup.i="documentationAction?.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
@ -249,6 +250,19 @@
} }
" "
/> />
<HoppSmartItem
v-if="isDocumentationVisible"
ref="documentationAction"
:icon="IconBook"
:label="t('documentation.title')"
:shortcut="['I']"
@click="
() => {
emit('open-documentation')
hide()
}
"
/>
<HoppSmartItem <HoppSmartItem
ref="propertiesAction" ref="propertiesAction"
:icon="IconSettings2" :icon="IconSettings2"
@ -301,6 +315,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useDocumentationVisibility } from "~/composables/documentationVisibility"
import { HoppCollection } from "@hoppscotch/data" import { HoppCollection } from "@hoppscotch/data"
import { computed, ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
@ -324,6 +339,7 @@ import IconServer from "~icons/lucide/server"
import IconSettings2 from "~icons/lucide/settings-2" import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
import IconArrowUpDown from "~icons/lucide/arrow-up-down" import IconArrowUpDown from "~icons/lucide/arrow-up-down"
import IconBook from "~icons/lucide/book"
import { CurrentSortValuesService } from "~/services/current-sort.service" import { CurrentSortValuesService } from "~/services/current-sort.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { useMockServerStatus } from "~/composables/mockServer" import { useMockServerStatus } from "~/composables/mockServer"
@ -380,6 +396,7 @@ const emit = defineEmits<{
(event: "edit-collection"): void (event: "edit-collection"): void
(event: "edit-properties"): void (event: "edit-properties"): void
(event: "duplicate-collection"): void (event: "duplicate-collection"): void
(event: "open-documentation"): void
(event: "export-data"): void (event: "export-data"): void
(event: "remove-collection"): void (event: "remove-collection"): void
(event: "create-mock-server"): void (event: "create-mock-server"): void
@ -411,6 +428,9 @@ const options = ref<TippyComponent | null>(null)
const propertiesAction = ref<HTMLButtonElement | null>(null) const propertiesAction = ref<HTMLButtonElement | null>(null)
const runCollectionAction = ref<HTMLButtonElement | null>(null) const runCollectionAction = ref<HTMLButtonElement | null>(null)
const sortAction = ref<HTMLButtonElement | null>(null) const sortAction = ref<HTMLButtonElement | null>(null)
const documentationAction = ref<HTMLButtonElement | null>(null)
const { isDocumentationVisible } = useDocumentationVisibility()
const dragging = ref(false) const dragging = ref(false)
const ordering = ref(false) const ordering = ref(false)

View file

@ -171,6 +171,7 @@ function translateToTeamCollectionFormat(x: HoppCollection) {
auth: x.auth, auth: x.auth,
headers: x.headers, headers: x.headers,
variables: x.variables, variables: x.variables,
description: x.description,
} }
const obj = { const obj = {
@ -193,6 +194,7 @@ function translateToPersonalCollectionFormat(x: HoppCollection) {
auth: x.auth, auth: x.auth,
headers: x.headers, headers: x.headers,
variables: x.variables, variables: x.variables,
description: x.description,
} }
const obj = { const obj = {

View file

@ -92,6 +92,14 @@
collectionSyncID: node.data.data.data.id, collectionSyncID: node.data.data.data.id,
}) })
" "
@open-documentation="
node.data.type === 'collections' &&
emit('open-documentation', {
pathOrID: node.id,
collectionRefID: node.data.data.data._ref_id,
collection: node.data.data.data,
})
"
@edit-properties=" @edit-properties="
node.data.type === 'collections' && node.data.type === 'collections' &&
emit('edit-properties', { emit('edit-properties', {
@ -189,6 +197,14 @@
collectionSyncID: node.data.data.data.id, collectionSyncID: node.data.data.data.id,
}) })
" "
@open-documentation="
node.data.type === 'folders' &&
emit('open-documentation', {
pathOrID: node.id,
collectionRefID: node.data.data.data._ref_id,
collection: node.data.data.data,
})
"
@edit-properties=" @edit-properties="
node.data.type === 'folders' && node.data.type === 'folders' &&
emit('edit-properties', { emit('edit-properties', {
@ -279,6 +295,15 @@
request: node.data.data.data, request: node.data.data.data,
}) })
" "
@open-request-documentation="
node.data.type === 'requests' &&
emit('open-request-documentation', {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
requestRefID: node.data.data.data._ref_id,
request: node.data.data.data,
})
"
@duplicate-response=" @duplicate-response="
emit('duplicate-response', { emit('duplicate-response', {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@ -559,6 +584,23 @@ const emit = defineEmits<{
collectionSyncID?: string collectionSyncID?: string
} }
): void ): void
(
event: "open-documentation",
payload: {
pathOrID: string
collectionRefID: string
collection: HoppCollection
}
): void
(
event: "open-request-documentation",
payload: {
folderPath: string
requestIndex: string
requestRefID: string
request: HoppRESTRequest
}
): void
( (
event: "edit-properties", event: "edit-properties",
payload: { payload: {

View file

@ -71,9 +71,9 @@
</span> </span>
</span> </span>
</div> </div>
<div v-if="!hasNoTeamAccess" class="flex"> <div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-if="!saveRequest" v-if="!saveRequest && !hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW" :icon="IconRotateCCW"
:title="t('action.restore')" :title="t('action.restore')"
@ -102,9 +102,11 @@
@keyup.d="duplicate?.$el.click()" @keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()" @keyup.delete="deleteAction?.$el.click()"
@keyup.s="shareAction?.$el.click()" @keyup.s="shareAction?.$el.click()"
@keyup.i="documentationAction?.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
v-if="!hasNoTeamAccess"
ref="edit" ref="edit"
:icon="IconEdit" :icon="IconEdit"
:label="t('action.edit')" :label="t('action.edit')"
@ -117,6 +119,7 @@
" "
/> />
<HoppSmartItem <HoppSmartItem
v-if="!hasNoTeamAccess"
ref="duplicate" ref="duplicate"
:icon="IconCopy" :icon="IconCopy"
:label="t('action.duplicate')" :label="t('action.duplicate')"
@ -129,6 +132,20 @@
" "
/> />
<HoppSmartItem <HoppSmartItem
v-if="isDocumentationVisible"
ref="documentationAction"
:icon="IconBook"
:label="t('documentation.title')"
:shortcut="['I']"
@click="
() => {
emit('open-request-documentation')
hide()
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess"
ref="shareAction" ref="shareAction"
:icon="IconShare2" :icon="IconShare2"
:label="t('action.share')" :label="t('action.share')"
@ -141,6 +158,7 @@
" "
/> />
<HoppSmartItem <HoppSmartItem
v-if="!hasNoTeamAccess"
ref="deleteAction" ref="deleteAction"
:icon="IconTrash2" :icon="IconTrash2"
:label="t('action.delete')" :label="t('action.delete')"
@ -211,9 +229,11 @@ import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconShare2 from "~icons/lucide/share-2" import IconShare2 from "~icons/lucide/share-2"
import IconArrowRight from "~icons/lucide/chevron-right" import IconArrowRight from "~icons/lucide/chevron-right"
import IconArrowDown from "~icons/lucide/chevron-down" import IconArrowDown from "~icons/lucide/chevron-down"
import IconBook from "~icons/lucide/book"
import { ref, PropType, watch, computed } from "vue" import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useDocumentationVisibility } from "~/composables/documentationVisibility"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
import { import {
changeCurrentReorderStatus, changeCurrentReorderStatus,
@ -293,6 +313,7 @@ const emit = defineEmits<{
(event: "edit-request"): void (event: "edit-request"): void
(event: "edit-response", payload: ResponsePayload): void (event: "edit-response", payload: ResponsePayload): void
(event: "duplicate-request"): void (event: "duplicate-request"): void
(event: "open-request-documentation"): void
(event: "remove-request"): void (event: "remove-request"): void
(event: "select-request"): void (event: "select-request"): void
(event: "share-request"): void (event: "share-request"): void
@ -311,6 +332,9 @@ const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null) const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null) const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null) const shareAction = ref<HTMLButtonElement | null>(null)
const documentationAction = ref<HTMLButtonElement | null>(null)
const { isDocumentationVisible } = useDocumentationVisibility()
const dragging = ref(false) const dragging = ref(false)
const ordering = ref(false) const ordering = ref(false)

View file

@ -118,6 +118,14 @@
collection: node.data.data.data, collection: node.data.data.data,
}) })
" "
@open-documentation="
node.data.type === 'collections' &&
emit('open-documentation', {
pathOrID: node.id,
collectionRefID: node.data.data.data.id,
collection: node.data.data.data,
})
"
@create-mock-server=" @create-mock-server="
node.data.type === 'collections' && node.data.type === 'collections' &&
emit('create-mock-server', { emit('create-mock-server', {
@ -226,6 +234,14 @@
collection: node.data.data.data, collection: node.data.data.data,
}) })
" "
@open-documentation="
node.data.type === 'folders' &&
emit('open-documentation', {
pathOrID: node.id,
collectionRefID: node.data.data.data.id,
collection: node.data.data.data,
})
"
@export-data=" @export-data="
node.data.type === 'folders' && node.data.type === 'folders' &&
emit('export-data', node.data.data.data) emit('export-data', node.data.data.data)
@ -309,6 +325,15 @@
request: node.data.data.data.request, request: node.data.data.data.request,
}) })
" "
@open-request-documentation="
node.data.type === 'requests' &&
emit('open-request-documentation', {
folderPath: getPath(node.id),
requestIndex: node.data.data.data.id,
requestRefID: node.data.data.data.id,
request: node.data.data.data.request,
})
"
@edit-response=" @edit-response="
emit('edit-response', { emit('edit-response', {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@ -657,6 +682,23 @@ const emit = defineEmits<{
} }
): void ): void
(event: "select-response", payload: ResponsePayload): void (event: "select-response", payload: ResponsePayload): void
(
event: "open-documentation",
payload: {
pathOrID: string
collectionRefID: string
collection: TeamCollection
}
): void
(
event: "open-request-documentation",
payload: {
folderPath: string
requestIndex: string
requestRefID?: string
request: HoppRESTRequest
}
): void
( (
event: "share-request", event: "share-request",
payload: { payload: {

View file

@ -0,0 +1,169 @@
<template>
<div class="flex-1 overflow-y-auto">
<div
v-if="collection"
class="flex-1 min-w-0 flex flex-col space-y-8 overflow-y-auto"
>
<div class="px-4">
<h1 class="text-3xl font-bold text-secondaryDark my-2">
{{ collectionName }}
</h1>
</div>
<!-- Collection Documentation -->
<div class="">
<CollectionsDocumentationMarkdownEditor
v-model="editableContent"
:placeholder="t('documentation.add_description')"
:read-only="readOnly"
@blur="handleBlur"
/>
</div>
<CollectionsDocumentationSectionsAuth
:auth="collectionAuth"
:inherited-auth="inheritedProperties?.auth"
/>
<CollectionsDocumentationSectionsVariables
:variables="collectionVariables"
/>
<CollectionsDocumentationSectionsHeaders
:headers="collectionHeaders"
:inherited-headers="inheritedProperties?.headers"
/>
</div>
<div v-else class="text-center py-8 text-secondaryLight">
<icon-lucide-file-question class="mx-auto mb-2" size="32" />
<p>{{ t("documentation.no_collection_data") }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import {
HoppCollection,
HoppRESTAuth,
HoppRESTHeaders,
HoppCollectionVariable,
} from "@hoppscotch/data"
import { ref, computed } from "vue"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { DocumentationService } from "~/services/documentation.service"
import { useI18n } from "~/composables/i18n"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { cascadeParentCollectionForProperties } from "~/newstore/collections"
const t = useI18n()
type CollectionType = HoppCollection | null
const props = withDefaults(
defineProps<{
documentationDescription: string
collection: CollectionType
pathOrID: string | null
folderPath?: string
isTeamCollection?: boolean
collectionPath?: string
teamID?: string
readOnly?: boolean
inheritedProperties?: HoppInheritedProperty
}>(),
{
documentationDescription: "",
collection: null,
pathOrID: null,
folderPath: "",
isTeamCollection: false,
collectionPath: "",
teamID: "",
readOnly: false,
inheritedProperties: undefined,
}
)
const emit = defineEmits<{
(event: "update:documentationDescription", value: string): void
}>()
const teamCollectionsService = useService(TeamCollectionsService)
const inheritedProperties = computed(() => {
if (props.inheritedProperties) {
return props.inheritedProperties
}
if (props.isTeamCollection && props.teamID && props.folderPath) {
return teamCollectionsService.cascadeParentCollectionForProperties(
props.folderPath
)
}
if (!props.isTeamCollection && props.folderPath) {
return cascadeParentCollectionForProperties(props.folderPath, "rest")
}
return undefined
})
// Extract collection name with fallback for null collections
const collectionName = computed<string>(() => {
if (!props.collection) return ""
return props.collection.name
})
// Extract collection auth configuration with inherit default
const collectionAuth = computed<HoppRESTAuth | null>(() => {
if (!props.collection) return null
return props.collection.auth || { authType: "inherit", authActive: true }
})
// Extract collection variables with empty array fallback
const collectionVariables = computed<HoppCollectionVariable[]>(() => {
if (!props.collection) return []
return props.collection.variables || []
})
// Extract collection headers with empty array fallback
const collectionHeaders = computed<HoppRESTHeaders>(() => {
if (!props.collection) return []
return props.collection.headers || []
})
const collectionDescription = useVModel(
props,
"documentationDescription",
emit,
{ passive: true }
)
const documentationService = useService(DocumentationService)
// Edit mode state and content management
const editableContent = ref<string>(collectionDescription.value)
// Handle blur event - save changes and exit edit mode
function handleBlur(): void {
const hasChanged = editableContent.value !== collectionDescription.value
if (hasChanged && (props.collection?.id || props.collection?._ref_id)) {
documentationService.setCollectionDocumentation(
props.collection.id ?? props.collection._ref_id!,
editableContent.value,
{
isTeamItem: props.isTeamCollection,
pathOrID: props.pathOrID ?? props.folderPath,
teamID: props.teamID,
collectionData: props.collection as HoppCollection,
}
)
}
emit("update:documentationDescription", editableContent.value)
}
</script>

View file

@ -0,0 +1,263 @@
<template>
<div
class="rounded-md border border-divider"
:class="{
' w-64': isDocModal,
}"
>
<div
class="sticky top-0 z-[99] py-2 border-b border-divider bg-primaryLight flex items-center justify-between space-x-3"
>
<div
class="font-medium text-secondaryDark flex flex-1 items-center text-xs px-2 truncate cursor-pointer transition-colors"
@click="scrollToTop"
>
<span class="truncate">
{{ collectionName }}
</span>
</div>
<HoppSmartItem
v-if="hasItems(collectionFolders) || hasItems(collectionRequests)"
:icon="allExpanded ? IconCheveronsUp : IconCheveronsDown"
class="focus-visible:bg-transparent hover:bg-transparent"
@click="toggleAllFolders"
/>
</div>
<!-- Tree structure -->
<div
class="overflow-y-auto"
:class="{
'!max-h-[400px]': isDocModal,
}"
>
<div v-if="hasItems(collectionFolders)">
<CollectionsDocumentationFolderItem
v-for="(rootFolder, rootFolderIndex) in collectionFolders"
:key="getFolderId(rootFolder, rootFolderIndex)"
:folder="rootFolder"
:folder-index="rootFolderIndex"
:depth="0"
:expanded-folders="expandedFolders"
@toggle-folder="toggleFolder"
@request-select="onRequestSelect"
@folder-select="onFolderSelect"
/>
</div>
<!-- Root Requests -->
<div v-if="hasItems(collectionRequests)" class="ml-4">
<CollectionsDocumentationRequestItem
v-for="(request, requestIndex) in collectionRequests"
:key="getRequestId(request, requestIndex)"
:request="request as HoppRESTRequest"
:depth="0"
@request-select="onRequestSelect"
/>
</div>
<div
v-if="!hasItems(collectionFolders) && !hasItems(collectionRequests)"
class="p-3 text-center text-secondaryLight text-xs italic"
>
{{ t("documentation.no_requests_or_folders") }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
HoppCollection,
HoppRESTRequest,
HoppGQLRequest,
} from "@hoppscotch/data"
import { ref, reactive, watch, computed } from "vue"
import IconCheveronsDown from "~icons/lucide/chevrons-down"
import IconCheveronsUp from "~icons/lucide/chevrons-up"
import { useService } from "dioc/vue"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
type ExpandedFoldersType = { [key: string]: boolean }
type HoppRequest = HoppRESTRequest | HoppGQLRequest
type ItemWithPossibleId = {
id?: string
_ref_id?: string
name?: string
[key: string]: unknown
}
const props = withDefaults(
defineProps<{
collection: HoppCollection
initiallyExpanded?: boolean
isDocModal?: boolean
}>(),
{
initiallyExpanded: false,
isDocModal: true,
}
)
const emit = defineEmits<{
(e: "request-select", request: HoppRESTRequest): void
(e: "folder-select", folder: HoppCollection): void
(e: "scroll-to-top"): void
}>()
const expandedFolders = reactive<ExpandedFoldersType>({})
const allExpanded = ref<boolean>(props.initiallyExpanded || false)
const teamCollectionService = useService(TeamCollectionsService)
const collectionFolders = computed<HoppCollection[]>(() => {
return props.collection.folders || []
})
const collectionRequests = computed<HoppRequest[]>(() => {
return props.collection.requests
})
const collectionName = computed<string>(() => {
return props.collection.name || t("documentation.untitled_collection")
})
/**
* Generate a fallback ID for items that don't have one
* @param item The folder or request item
* @param index The index of the item in its parent array
* @param prefix The prefix to use for the generated ID
* @returns A generated ID string
*/
const generateFallbackId = (
item: ItemWithPossibleId,
index: number,
prefix: string
): string => {
return (
item.id ||
item._ref_id ||
`${prefix}-${item.name?.replace(/\s+/g, "-").toLowerCase()}-${index}`
)
}
/**
* Get a reliable ID for a folder, with fallback generation
* @param folder The folder object
* @param index The folder's index in its parent array
* @returns A reliable ID string
*/
const getFolderId = (folder: HoppCollection, index: number): string => {
return generateFallbackId(folder, index, "folder")
}
// Initialize folder structure with first level expanded
watch(
() => props.collection,
() => {
const folders = collectionFolders.value
if (folders?.length) {
folders.forEach((folder: HoppCollection, index: number) => {
const folderId = getFolderId(folder, index)
expandedFolders[folderId] = true
})
}
},
{ immediate: true }
)
const toggleFolder = async (folderId: string) => {
expandedFolders[folderId] = !expandedFolders[folderId]
await teamCollectionService.expandCollection(folderId)
}
const toggleAllFolders = () => {
allExpanded.value = !allExpanded.value
const processAllFolders = (folders: HoppCollection[]) => {
folders.forEach((folder, index) => {
const folderId = getFolderId(folder, index)
expandedFolders[folderId] = allExpanded.value
if (folder.folders && folder.folders.length > 0) {
processAllFolders(folder.folders)
}
})
}
const folders = collectionFolders.value
if (folders?.length) {
processAllFolders(folders)
}
}
const onRequestSelect = (request: HoppRESTRequest) => {
emit("request-select", request)
}
const onFolderSelect = (folder: HoppCollection) => {
emit("folder-select", folder)
}
/**
* Emits event to scroll to the top of the documentation
*/
const scrollToTop = () => {
emit("scroll-to-top")
}
/**
* Type guard to check if a request is a REST request
* @returns True if the request is a REST request
*/
/**
* Check if a value exists and has length > 0
* @param value Array to check
* @returns Boolean indicating if array has items
*/
const hasItems = <T,>(value: T[] | undefined): boolean => {
return !!value && value.length > 0
}
/**
* Get a reliable ID for a request, with fallback generation
* @param request The request object
* @param index The request's index in its parent array
* @returns A reliable ID string
*/
const getRequestId = (request: HoppRequest, index: number): string => {
return generateFallbackId(request, index, "request")
}
</script>
<style scoped>
.scrollable-structure {
scrollbar-width: thin;
}
/* Custom scrollbar styling */
.scrollable-structure::-webkit-scrollbar {
width: 6px;
}
.scrollable-structure::-webkit-scrollbar-track {
@apply bg-transparent;
}
.scrollable-structure::-webkit-scrollbar-thumb {
@apply bg-divider rounded-full;
}
.scrollable-structure::-webkit-scrollbar-thumb:hover {
@apply bg-dividerLight;
}
/* Animation for folder expansion */
.transition-transform-2 {
@apply transition-transform duration-200 ease-in-out;
}
</style>

View file

@ -0,0 +1,166 @@
<template>
<div>
<div
class="px-2 py-1.5 flex items-center cursor-pointer group"
@click.stop="emit('toggle-folder', currentFolderId)"
>
<span
class="w-4 h-4 flex items-center justify-center transition-transform-2"
:class="{
'transform rotate-90': expandedFolders[currentFolderId],
}"
>
<icon-lucide-chevron-right class="svg-icons" />
</span>
<icon-lucide-folder
v-if="!expandedFolders[currentFolderId]"
class="ml-1 mr-1.5 svg-icons transition-colors group-hover:text-secondaryDark"
/>
<icon-lucide-folder-open
v-else
class="ml-1 mr-1.5 svg-icons transition-colors group-hover:text-secondaryDark"
/>
<span
class="text-xs truncate flex-1 transition-colors group-hover:text-secondaryDark"
@click.stop="emit('folder-select', folder)"
>
{{ folderName }}
</span>
</div>
<div v-if="expandedFolders[currentFolderId]">
<div v-if="hasItems(childFolders)" class="mb-1">
<div
v-for="(nestedFolder, nestedIndex) in childFolders"
:key="getFolderId(nestedFolder, nestedIndex)"
class="pl-4"
>
<CollectionsDocumentationFolderItem
:folder="nestedFolder"
:folder-index="nestedIndex"
:depth="depth + 1"
:expanded-folders="expandedFolders"
@toggle-folder="emit('toggle-folder', $event)"
@request-select="emit('request-select', $event)"
@folder-select="emit('folder-select', $event)"
/>
</div>
</div>
<div v-if="hasItems(folder.requests)" class="mb-1 pl-8">
<CollectionsDocumentationRequestItem
v-for="(request, requestIndex) in folder.requests"
:key="getRequestId(request, requestIndex)"
:request="request as HoppRESTRequest"
:depth="depth + 1"
@request-select="emit('request-select', $event)"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
HoppCollection,
HoppRESTRequest,
HoppGQLRequest,
} from "@hoppscotch/data"
import { computed } from "vue"
import CollectionsDocumentationFolderItem from "./FolderItem.vue"
type ExpandedFoldersType = { [key: string]: boolean }
type HoppRequest = HoppRESTRequest | HoppGQLRequest
type ItemWithPossibleId = {
id?: string
_ref_id?: string
name?: string
[key: string]: unknown
}
const props = defineProps<{
folder: HoppCollection
folderIndex: number
depth: number
expandedFolders: ExpandedFoldersType
}>()
const folderName = computed(() => props.folder.name)
const emit = defineEmits<{
(e: "toggle-folder", folderId: string): void
(e: "request-select", request: HoppRESTRequest): void
(e: "folder-select", folder: HoppCollection): void
}>()
const childFolders = computed<Array<HoppCollection>>(() => {
return props.folder.folders || []
})
const currentFolderId = computed(() =>
getFolderId(props.folder, props.folderIndex)
)
/**
* Type guard to check if a request is a REST request
* @param request The request object to check
* @returns True if the request is a REST request
*/
/**
* Check if a value exists and has length > 0
* @param value Array to check
* @returns Boolean indicating if array has items
*/
function hasItems<T>(value: T[] | undefined): boolean {
return !!value && value.length > 0
}
/**
* Generate a fallback ID for items that don't have one
* @param item The folder or request item
* @param index The index of the item in its parent array
* @param prefix The prefix to use for the generated ID
* @returns A generated ID string
*/
function generateFallbackId(
item: ItemWithPossibleId,
index: number,
prefix: string
): string {
// Handle regular items with id, _ref_id, and name properties
return (
item.id ||
item._ref_id ||
`${prefix}-${item.name?.replace(/\s+/g, "-").toLowerCase()}-${index}`
)
}
/**
* Get a reliable ID for a folder, with fallback generation
* @param folder The folder object
* @param index The folder's index in its parent array
* @returns A reliable ID string
*/
function getFolderId(folder: HoppCollection, index: number): string {
return generateFallbackId(folder, index, "folder")
}
/**
* Get a reliable ID for a request, with fallback generation
* @param request The request object
* @param index The request's index in its parent array
* @returns A reliable ID string
*/
function getRequestId(request: HoppRequest, index: number): string {
return generateFallbackId(request, index, "request")
}
</script>
<style scoped>
/* Animation for folder expansion */
.transition-transform-2 {
transition: transform 200ms ease-in-out;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div ref="container" class="lazy-item-container">
<div v-if="shouldRender" class="lazy-item-content">
<slot></slot>
</div>
<div
v-else
class="lazy-item-placeholder"
:style="{ height: placeholderHeight }"
></div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useIntersectionObserver } from "@vueuse/core"
const props = defineProps({
minHeight: {
type: String,
default: "100px",
},
forceRender: {
type: Boolean,
default: false,
},
})
const container = ref<HTMLElement | null>(null)
const shouldRender = ref(false)
const placeholderHeight = ref(props.minHeight)
import { watch } from "vue"
watch(
() => props.forceRender,
(newVal) => {
if (newVal) {
shouldRender.value = true
}
},
{ immediate: true }
)
// Once rendered, keep it rendered to avoid flickering
const { stop } = useIntersectionObserver(
container,
([{ isIntersecting }]) => {
if (isIntersecting || props.forceRender) {
shouldRender.value = true
stop() // Stop observing once rendered
}
},
{
rootMargin: "600px 0px 600px 0px", // Pre-load items well before they enter viewport
threshold: 0.01,
}
)
</script>
<style scoped>
.lazy-item-container {
width: 100%;
}
.lazy-item-placeholder {
width: 100%;
background: transparent;
}
</style>

View file

@ -0,0 +1,293 @@
<template>
<div class="rounded-sm relative h-full" @click.stop>
<!-- Edit mode textarea -->
<template v-if="editMode && !readOnly">
<textarea
ref="textareaRef"
v-model="internalContent"
class="text-wrap w-full p-4 rounded-sm text-sm font-mono text-secondaryLight outline-none resize-none focus:border focus:border-accent focus:bg-primaryLight transition"
:style="{ height: textareaHeight + 'px' }"
spellcheck="false"
:placeholder="placeholder"
@blur="handleBlur"
@click.stop
@input="adjustTextareaHeight"
></textarea>
</template>
<!-- Preview mode with rendered markdown (safe: sanitized by DOMPurify) -->
<!-- eslint-disable vue/no-v-html -->
<div
v-else
:class="[
'min-h-52 p-4 prose prose-invert prose-sm max-w-none markdown-content relative border border-transparent rounded-sm',
{
'cursor-text hover:bg-primaryLight transition': !readOnly,
'cursor-text select-auto': readOnly,
},
]"
@click.stop="enableEditMode"
v-html="renderedMarkdown"
></div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted } from "vue"
import MarkdownIt from "markdown-it"
import DOMPurify from "dompurify"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
readOnly?: boolean
}>(),
{
modelValue: "",
placeholder: "Add description here...",
readOnly: false,
}
)
const emit = defineEmits<{
(event: "update:modelValue", value: string): void
(event: "blur"): void
}>()
// Initialize MarkdownIt
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
// Edit mode state and content management
const editMode = ref<boolean>(false)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const textareaHeight = ref<number>(200)
// Internal content that syncs with modelValue
const internalContent = ref<string>(props.modelValue)
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newValue) => {
if (!editMode.value) {
internalContent.value = newValue
}
},
{ immediate: true }
)
// Render markdown content with DOMPurify sanitization
const renderedMarkdown = computed(() => {
try {
if (!internalContent.value || internalContent.value.trim() === "") {
return DOMPurify.sanitize(
`<p class='text-secondaryLight italic'>${props.placeholder || t("documentation.add_description_placeholder")}</p>`
)
}
// Render markdown first, then sanitize the HTML output with DOMPurify
const rawHtml = md.render(internalContent.value)
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return DOMPurify.sanitize(
`<div class="text-red-400">${t("documentation.error_rendering_markdown")} ${errorMessage}</div>`
)
}
})
// Dynamically adjust textarea height to fit content
const adjustTextareaHeight = () => {
if (textareaRef.value) {
textareaRef.value.style.height = "auto"
const newHeight = textareaRef.value.scrollHeight + 4
const minHeight = 208
textareaHeight.value = Math.max(newHeight, minHeight)
textareaRef.value.style.height = `${textareaHeight.value}px`
}
}
// Switch to edit mode and focus the textarea
const enableEditMode = () => {
// Prevent editing if readOnly is true
if (props.readOnly) return
editMode.value = true
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.focus()
adjustTextareaHeight()
}
})
}
// Handle blur event - save changes and exit edit mode
const handleBlur = () => {
emit("update:modelValue", internalContent.value)
emit("blur")
editMode.value = false
}
// Watch for content changes to dynamically adjust textarea height
watch(internalContent, () => {
nextTick(() => {
adjustTextareaHeight()
})
})
// Initialize textarea height when component mounts
onMounted(() => {
if (editMode.value) {
nextTick(() => {
adjustTextareaHeight()
})
}
})
</script>
<style scoped>
/* Markdown content styles */
.markdown-content :deep(a) {
@apply text-accent hover:underline;
}
/* Heading styles */
.markdown-content :deep(h1) {
@apply text-lg font-semibold text-secondaryDark mt-4 mb-2;
}
.markdown-content :deep(h2) {
@apply text-base font-semibold text-secondaryDark mt-4 mb-2;
}
.markdown-content :deep(h3) {
@apply text-sm font-medium text-secondaryDark mt-3 mb-2;
}
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
@apply text-xs font-medium text-secondaryDark mt-2 mb-1;
}
/* Paragraph and text styles */
.markdown-content :deep(p) {
@apply text-sm my-2 leading-relaxed text-secondary;
}
.markdown-content :deep(strong) {
@apply font-semibold text-secondaryDark;
}
.markdown-content :deep(em) {
@apply italic;
}
.markdown-content :deep(del) {
@apply line-through;
}
/* List styles */
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
@apply pl-6 my-3 text-sm text-secondaryLight space-y-1;
}
.markdown-content :deep(li > ul),
.markdown-content :deep(li > ol) {
@apply my-1 ml-4;
}
.markdown-content :deep(ul) {
@apply list-disc;
}
.markdown-content :deep(ol) {
@apply list-decimal;
}
.markdown-content :deep(li) {
@apply my-1 leading-relaxed;
}
/* Code styles */
.markdown-content :deep(code) {
@apply bg-primaryLight text-accent px-1.5 py-0.5 rounded font-mono text-xs;
}
.markdown-content :deep(pre) {
@apply bg-primaryLight p-3 rounded my-3 overflow-auto;
}
.markdown-content :deep(pre code) {
@apply bg-transparent p-0 text-xs leading-normal block;
}
.markdown-content :deep(blockquote) {
@apply border-l-4 border-divider pl-4 italic text-secondaryLight my-4 text-sm bg-primaryDark py-2 rounded-r;
}
.markdown-content :deep(hr) {
@apply my-6 border-none h-px;
}
.markdown-content :deep(table) {
@apply border-collapse w-full my-4 text-xs;
}
.markdown-content :deep(th) {
@apply border border-divider px-3 py-2 text-left font-medium text-secondaryDark;
@apply bg-divider;
}
.markdown-content :deep(td) {
@apply border border-divider px-3 py-1 text-secondaryLight;
@apply bg-primaryDark;
}
.markdown-content :deep(thead tr:hover),
.markdown-content :deep(tbody tr:hover) {
@apply bg-divider;
}
.markdown-content :deep(th:first-child),
.markdown-content :deep(td:first-child) {
@apply text-xs;
}
.markdown-content :deep(th:nth-child(2)),
.markdown-content :deep(td:nth-child(2)) {
@apply text-sm;
}
.markdown-content :deep(th:nth-child(3)),
.markdown-content :deep(td:nth-child(3)) {
@apply text-base;
}
.markdown-content :deep(th:nth-child(4)),
.markdown-content :deep(td:nth-child(4)) {
@apply text-lg;
}
.markdown-content :deep(th:nth-child(5)),
.markdown-content :deep(td:nth-child(5)) {
@apply text-xs;
}
.markdown-content :deep(th:last-child),
.markdown-content :deep(td:last-child) {
@apply pl-4 text-xs;
}
</style>

View file

@ -0,0 +1,512 @@
<template>
<div
id="documentation-container"
ref="documentationContainerRef"
class="rounded-md flex-1 relative overflow-y-auto h-full"
>
<div
v-if="isLoading"
class="absolute inset-0 bg-primary backdrop-blur-sm z-50 flex items-center justify-center"
>
<div class="flex flex-col items-center space-y-4 text-center">
<icon-lucide-loader-2 class="animate-spin" size="32" />
<div class="text-sm text-secondaryLight">
<p class="font-medium mb-1">{{ currentLoadingMessage }}</p>
<p v-if="!props.isExternalLoading" class="text-xs">
{{ displayProgress }}{{ t("documentation.percent_complete") }}
</p>
</div>
</div>
</div>
<div v-if="collection" class="flex items-start relative">
<div class="flex-1 min-w-0 p-4">
<template v-if="!showAllDocumentation">
<CollectionsDocumentationCollectionPreview
v-if="selectedFolder"
:collection="selectedFolder"
:documentation-description="selectedFolder.description || ''"
:folder-path="folderPath ?? undefined"
:path-or-i-d="pathOrID"
:is-team-collection="isTeamCollection"
:collection-path="collectionPath || undefined"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
/>
<CollectionsDocumentationRequestPreview
v-else-if="selectedRequest"
:request="selectedRequest"
:documentation-description="selectedRequest.description || ''"
:collection-i-d="collectionID"
:collection-path="collectionPath"
:folder-path="folderPath"
:request-index="requestIndex"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
@update:documentation-description="
(value) => {
if (selectedRequest) {
selectedRequest.description = value
}
}
"
@close-modal="closeModal"
/>
<CollectionsDocumentationCollectionPreview
v-else
:collection="collection"
:documentation-description="documentationDescription"
:folder-path="folderPath ?? undefined"
:path-or-i-d="pathOrID"
:is-team-collection="isTeamCollection"
:collection-path="collectionPath || undefined"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
@update:documentation-description="
(value) => emit('update:documentationDescription', value)
"
/>
</template>
<!-- All Documentation View -->
<template v-else>
<div class="mb-8 overflow-hidden">
<CollectionsDocumentationCollectionPreview
v-model:documentation-description="collectionDescription"
:collection="collection"
:folder-path="folderPath ?? undefined"
:path-or-i-d="pathOrID"
:is-team-collection="isTeamCollection"
:collection-path="collectionPath || undefined"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
/>
</div>
<div
v-if="allItems.length === 0"
class="p-8 text-center text-secondaryLight"
>
<icon-lucide-file-question class="mx-auto mb-2" size="32" />
<p>{{ t("documentation.no_documentation_found") }}</p>
</div>
<!-- Rendering of all items -->
<div v-else class="space-y-8">
<div
v-for="(item, index) in displayedItems"
:id="`doc-item-${item.id}`"
:key="item.id"
class="flex flex-col"
>
<LazyDocumentationItem
:min-height="MIN_HEIGHT_PER_ITEM"
:force-render="shouldForceRender(index)"
>
<div class="p-0">
<CollectionsDocumentationRequestPreview
v-if="item.type === 'request'"
:request="item.item as HoppRESTRequest"
:documentation-description="
(item.item as HoppRESTRequest).description || ''
"
:collection-i-d="collectionID"
:collection-path="collectionPath"
:folder-path="item.folderPath"
:request-index="item.requestIndex"
:request-i-d="item.requestID"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
@update:documentation-description="
(value) =>
((item.item as HoppRESTRequest).description = value)
"
@close-modal="closeModal"
/>
<CollectionsDocumentationCollectionPreview
v-else
:collection="item.item as HoppCollection"
:documentation-description="
(item.item as HoppCollection).description || ''
"
:folder-path="item.folderPath ?? undefined"
:path-or-i-d="item.pathOrID ?? null"
:is-team-collection="isTeamCollection"
:collection-path="collectionPath || undefined"
:team-i-d="teamID"
:read-only="!hasTeamWriteAccess"
@update:documentation-description="
(value) =>
((item.item as HoppCollection).description = value)
"
/>
</div>
</LazyDocumentationItem>
</div>
<!-- Dummy element for infinite scroll -->
<div
v-if="displayedItems.length < allItems.length"
ref="loadMoreTrigger"
class="h-4 w-full"
></div>
</div>
</template>
</div>
<div v-if="showAllDocumentation" class="p-4 sticky top-0">
<CollectionsDocumentationCollectionStructure
:collection="collection"
@request-select="handleRequestSelect"
@folder-select="handleFolderSelect"
@scroll-to-top="handleScrollToTop"
/>
</div>
</div>
<div v-else class="text-center py-8 text-secondaryLight">
<icon-lucide-file-question class="mx-auto mb-2" size="32" />
<p>{{ t("documentation.no_collection_data") }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, computed } from "vue"
import { useVModel, useIntersectionObserver } from "@vueuse/core"
import { useService } from "dioc/vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { DocumentationItem } from "~/composables/useDocumentationWorker"
import LazyDocumentationItem from "./LazyDocumentationItem.vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
type CollectionType = HoppCollection | null
const props = withDefaults(
defineProps<{
documentationDescription?: string
collection?: CollectionType
collectionID?: string
collectionPath?: string | null
folderPath?: string | null
pathOrID?: string | null
requestIndex?: number | null
requestID?: string | null
teamID?: string
isTeamCollection?: boolean
allItems?: Array<DocumentationItem>
showAllDocumentation?: boolean
isProcessingDocumentation?: boolean
processingProgress?: number
isExternalLoading?: boolean
hasTeamWriteAccess?: boolean
}>(),
{
documentationDescription: "",
collection: null,
collectionID: "",
collectionPath: null,
folderPath: null,
pathOrID: null,
requestIndex: null,
requestID: null,
teamID: undefined,
isTeamCollection: false,
allItems: () => [],
showAllDocumentation: false,
isProcessingDocumentation: false,
processingProgress: 0,
isExternalLoading: false,
hasTeamWriteAccess: true,
}
)
const emit = defineEmits<{
(event: "update:documentationDescription", value: string): void
(event: "close-modal"): void
}>()
const teamCollectionService = useService(TeamCollectionsService)
const collectionDescription = useVModel(
props,
"documentationDescription",
emit,
{ passive: true }
)
const selectedRequest = ref<HoppRESTRequest | null>(null)
const selectedFolder = ref<HoppCollection | null>(null)
const selectedItemId = ref<string | null>(null)
// Lazy loading state for lazy loading documentation items to prevent performance issues
const ITEMS_PER_PAGE = 40
const MIN_HEIGHT_PER_ITEM = "150px"
const displayedItems = ref<Array<DocumentationItem>>([])
const loadMoreTrigger = ref<HTMLElement | null>(null)
/**
* Loads more items into the displayed list when scrolling
*/
const loadMoreItems = () => {
if (displayedItems.value.length < props.allItems.length) {
const currentLength = displayedItems.value.length
const nextItems = props.allItems.slice(
currentLength,
currentLength + ITEMS_PER_PAGE
)
displayedItems.value = [...displayedItems.value, ...nextItems]
}
}
// Intersection Observer for infinite scroll
useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting) {
loadMoreItems()
}
},
{ threshold: 0.1 }
)
/**
* Computed property to determine if the collection is loading
* Loads when the collection is processing or external loading is active
* or when the team collection is loading
*/
const isLoading = computed(
() =>
props.isProcessingDocumentation ||
props.isExternalLoading ||
teamCollectionService.loadingCollections.value.length !== 0
)
/**
* Computed property to determine the current loading message
* Returns "Loading Collection Data..." when external loading is active
* Returns "Processing Documentation" when processing documentation is active
* Returns "Loading..." when neither is active
*/
const currentLoadingMessage = computed(() => {
if (props.isExternalLoading) {
return t("documentation.loading_collection_data")
}
if (props.isProcessingDocumentation) {
return t("documentation.processing_documentation")
}
return t("documentation.loading")
})
const displayProgress = computed(() => {
if (props.isExternalLoading) {
return 0 // Don't show progress during external loading
}
if (props.isProcessingDocumentation) {
return props.processingProgress
}
return 0 // No progress for other states
})
const selectedIndex = computed(() => {
return displayedItems.value.findIndex(
(item) => item.id === selectedItemId.value
)
})
/**
* Determines if an item should be forcefully rendered based on its proximity to the selected item
*/
const shouldForceRender = (index: number) => {
if (selectedIndex.value === -1) return false
// Render a buffer of 20 items around the selected item to prevent scroll jumping
return Math.abs(index - selectedIndex.value) <= 20
}
/**
* Scrolls to a specific item by its ID, loading it if necessary
*/
const scrollToItem = (id: string): void => {
// Check if item is in displayedItems, if not, load until it is
const itemIndex = props.allItems.findIndex((item) => item.id === id)
let shouldAutoScroll = false
if (itemIndex !== -1 && itemIndex >= displayedItems.value.length) {
// Load all items up to the target item plus a buffer page
const targetPage = Math.ceil((itemIndex + 1) / ITEMS_PER_PAGE)
const itemsToLoad = (targetPage + 1) * ITEMS_PER_PAGE
displayedItems.value = props.allItems.slice(0, itemsToLoad)
shouldAutoScroll = true
}
// Set selectedItemId immediately to trigger forceRender on the target item
selectedItemId.value = id
nextTick(() => {
// Use a small timeout to ensure layout is fully stable after forceRender
setTimeout(() => {
const element = document.getElementById(`doc-item-${id}`)
if (element) {
element.scrollIntoView({
behavior: shouldAutoScroll ? "auto" : "instant",
block: "start",
})
} else {
console.log("Item not found:", id)
}
}, 50)
})
}
/**
* Backup function that scrolls by name and type if ID is not available
*/
const scrollToItemByNameAndType = (
name: string,
type: "request" | "folder"
) => {
const itemIndex = props.allItems.findIndex(
(item: DocumentationItem) => item.item.name === name && item.type === type
)
if (itemIndex !== -1) {
const targetItem = props.allItems[itemIndex]
console.log(`Found ${type} at index: ${itemIndex}`)
scrollToItem(targetItem.id)
} else {
console.log(`${type} with name "${name}" not found in allItems`)
}
}
/**
* Handles a request being selected from the collection structure
*/
const handleRequestSelect = (request: HoppRESTRequest) => {
selectedRequest.value = request
selectedFolder.value = null
selectedItemId.value = request.id || null
if (props.showAllDocumentation) {
const requestId = request.id || request._ref_id
if (requestId) {
scrollToItem(requestId)
} else {
scrollToItemByNameAndType(request.name, "request")
}
}
}
/**
* Handles a folder being selected from the collection structure
*/
const handleFolderSelect = (folder: HoppCollection) => {
selectedFolder.value = folder
selectedRequest.value = null
selectedItemId.value = folder.id || null
if (props.showAllDocumentation) {
const folderId = folder.id || folder._ref_id
if (folderId) {
scrollToItem(folderId)
} else {
scrollToItemByNameAndType(folder.name, "folder")
}
}
}
/**
* Closes the modal by emitting the close-modal event
*/
const closeModal = () => {
emit("close-modal")
}
const documentationContainerRef = ref<HTMLElement | null>(null)
/**
* Scrolls the documentation container to the top
*/
const handleScrollToTop = () => {
if (documentationContainerRef.value) {
documentationContainerRef.value.scrollTo({
top: 0,
behavior: "smooth",
})
}
}
// Initialize displayed items when allItems changes
watch(
() => props.allItems,
(newItems) => {
if (newItems && newItems.length > 0) {
displayedItems.value = newItems.slice(0, ITEMS_PER_PAGE)
} else {
displayedItems.value = []
}
},
{ immediate: true }
)
// Watch for showAllDocumentation prop changes to reset state
watch(
() => props.showAllDocumentation,
(newValue) => {
if (!newValue) {
// Hiding all documentation - clear all selections to return to the main collection view
selectedRequest.value = null
selectedFolder.value = null
selectedItemId.value = null
// Reset displayed items
displayedItems.value = []
} else {
// Reset displayed items when showing documentation
if (props.allItems && props.allItems.length > 0) {
displayedItems.value = props.allItems.slice(0, ITEMS_PER_PAGE)
}
}
}
)
// Watch for collection changes to reset selection
watch(
() => props.collection,
() => {
selectedRequest.value = null
selectedFolder.value = null
selectedItemId.value = null
},
{ immediate: true }
)
</script>
<style scoped>
:deep(.scrollable-structure) {
scrollbar-width: thin;
}
:deep(.scrollable-structure::-webkit-scrollbar) {
width: 6px;
}
:deep(.scrollable-structure::-webkit-scrollbar-track) {
background: transparent;
}
:deep(.scrollable-structure::-webkit-scrollbar-thumb) {
background: var(--divider-color);
border-radius: 9999px;
}
:deep(.scrollable-structure::-webkit-scrollbar-thumb:hover) {
background: var(--divider-light-color);
}
#documentation-container {
min-height: 300px;
}
</style>

View file

@ -0,0 +1,287 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="modalTitle"
styles="sm:max-w-2xl"
@close="hideModal"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Title Input -->
<div>
<HoppSmartInput
v-model="publishTitle"
label="Title"
type="text"
:readonly="mode === 'view'"
:class="{ 'opacity-60 cursor-not-allowed': mode === 'view' }"
input-styles="floating-input"
/>
</div>
<!-- Additional Fields (will be enabled in the future) -->
<!-- Version Input -->
<!-- <div>
<label class="block text-sm font-medium text-secondaryDark mb-2">
{{ t("documentation.publish.doc_version") }}
</label>
<input
v-model="publishVersion"
type="text"
:readonly="mode === 'view'"
class="w-full px-3 py-2 border border-divider rounded bg-primary text-secondaryDark focus:outline-none focus:border-accent"
:class="{ 'opacity-60 cursor-not-allowed': mode === 'view' }"
placeholder="1.0.0"
/>
</div> -->
<!-- Auto-sync Toggle -->
<!-- <div class="flex items-start space-x-3">
<input
id="auto-sync"
v-model="autoSync"
type="checkbox"
:disabled="mode === 'view'"
class="mt-1"
:class="{ 'opacity-60 cursor-not-allowed': mode === 'view' }"
/>
<div>
<label
for="auto-sync"
class="text-sm font-medium text-secondaryDark cursor-pointer"
>
{{ t("documentation.publish.auto_sync") }}
</label>
<p class="text-xs text-secondaryLight mt-1">
{{ t("documentation.publish.auto_sync_description") }}
</p>
</div>
</div> -->
<!-- Published URL (shown after publishing or in update/view mode) -->
<div v-if="publishedUrl || mode !== 'create'" class="space-y-2">
<div class="flex items-center space-x-2">
<HoppSmartInput
v-model="publishedUrl"
:label="t('documentation.publish.published_url')"
type="text"
disabled
input-styles="floating-input"
class="flex-1 opacity-80 cursor-not-allowed"
/>
<HoppButtonSecondary :icon="copyIcon" outline @click="copyUrl" />
</div>
</div>
<div class="flex space-x-2">
<HoppButtonPrimary
:label="t('documentation.publish.view_published')"
:icon="IconExternalLink"
@click="viewPublished"
/>
<HoppButtonSecondary
v-if="mode === 'update'"
:icon="IconTrash2"
label="Delete Documentation"
class="!text-red-500"
:loading="loading"
:disabled="loading"
filled
outline
@click="showDeleteConfirmModal = true"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center w-full">
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
<HoppButtonPrimary
v-if="mode === 'create' && !publishedUrl"
:label="t('documentation.publish.button')"
:disabled="!canPublish || loading"
:loading="loading"
@click="handlePublish"
/>
<HoppButtonPrimary
v-else-if="mode === 'update'"
:label="t('documentation.publish.update_button')"
:disabled="!canPublish || loading || !hasChanges"
:loading="loading"
@click="handleUpdate"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="showDeleteConfirmModal"
:title="t('documentation.publish.delete_published_doc')"
:confirm="t('action.delete')"
:loading-state="loading"
@hide-modal="showDeleteConfirmModal = false"
@resolve="confirmDelete"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, markRaw } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { platform } from "~/platform"
import IconCopy from "~icons/lucide/copy"
import IconExternalLink from "~icons/lucide/external-link"
import IconCheck from "~icons/lucide/check"
import IconTrash2 from "~icons/lucide/trash-2"
import {
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
WorkspaceType,
} from "~/helpers/backend/graphql"
import { refAutoReset, useClipboard } from "@vueuse/core"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
show: boolean
collectionID: string
collectionTitle: string
workspaceType: WorkspaceType
workspaceID: string
mode?: "create" | "update" | "view"
publishedDocId?: string
existingData?: {
title: string
version: string
autoSync: boolean
url: string
}
loading?: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "publish", data: CreatePublishedDocsArgs): void
(e: "update", id: string, data: UpdatePublishedDocsArgs): void
(e: "published", url: string): void
(e: "delete"): void
}>()
const publishTitle = ref(props.existingData?.title || props.collectionTitle)
const publishVersion = ref(props.existingData?.version || "latest")
const autoSync = ref(props.existingData?.autoSync ?? true)
const publishedUrl = ref<string | null>(props.existingData?.url || null)
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
const { copy } = useClipboard()
const showDeleteConfirmModal = ref(false)
// Initialize form data based on existingData or defaults
const initializeFormData = () => {
if (props.existingData) {
publishTitle.value = props.existingData.title
publishVersion.value = props.existingData.version
autoSync.value = props.existingData.autoSync
publishedUrl.value = props.existingData.url
} else {
publishTitle.value = props.collectionTitle
publishVersion.value = "1.0.0"
autoSync.value = true
publishedUrl.value = null
}
}
// Watch for changes to existingData or modal visibility to update
watch(
[() => props.existingData, () => props.show],
([, isOpen]) => {
if (isOpen) {
initializeFormData()
}
},
{ immediate: true }
)
const modalTitle = computed(() => {
if (props.mode === "update") return t("documentation.publish.update_title")
if (props.mode === "view") return t("documentation.publish.view_title")
return t("documentation.publish.title")
})
const canPublish = computed(() => {
return publishTitle.value.trim().length > 0
})
const hasChanges = computed(() => {
if (!props.existingData) return true
return (
publishTitle.value.trim() !== props.existingData.title ||
publishVersion.value.trim() !== props.existingData.version ||
autoSync.value !== props.existingData.autoSync
)
})
const hideModal = () => {
emit("hide-modal")
}
const handlePublish = () => {
if (!canPublish.value) return
const doc: CreatePublishedDocsArgs = {
title: publishTitle.value.trim(),
version: publishVersion.value.trim(),
autoSync: autoSync.value,
workspaceType: props.workspaceType,
workspaceID: props.workspaceID,
collectionID: props.collectionID,
metadata: "{}",
}
emit("publish", doc)
}
const handleUpdate = () => {
if (!canPublish.value || !props.publishedDocId) return
const doc: UpdatePublishedDocsArgs = {
title: publishTitle.value.trim(),
version: publishVersion.value.trim(),
autoSync: autoSync.value,
metadata: "{}",
}
emit("update", props.publishedDocId, doc)
}
const copyUrl = () => {
if (publishedUrl.value) {
copyIcon.value = markRaw(IconCheck)
copy(publishedUrl.value)
toast.success(t("documentation.publish.url_copied"))
}
}
const viewPublished = () => {
if (publishedUrl.value) {
platform.kernelIO.openExternalLink({ url: publishedUrl.value })
}
}
const confirmDelete = () => {
emit("delete")
showDeleteConfirmModal.value = false
}
</script>

View file

@ -0,0 +1,70 @@
<template>
<div
class="py-1.5 space-x-2 flex items-center group cursor-pointer"
@click.stop="$emit('request-select', actualRequest)"
>
<span
class="text-tiny px-1 rounded-sm"
:class="getMethodClass(requestMethod)"
>
{{ requestMethod }}
</span>
<span
class="text-secondaryLight text-xs truncate transition-colors group-hover:text-secondaryDark"
>
{{ requestName }}
</span>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed } from "vue"
type HoppRequest = HoppRESTRequest
const props = defineProps<{
request: HoppRequest
depth?: number
}>()
defineEmits<{
(e: "request-select", request: HoppRESTRequest): void
}>()
const actualRequest = computed<HoppRESTRequest>(() => {
return props.request
})
const requestName = computed<string>(() => {
return props.request.name || "Untitled Request"
})
const requestMethod = computed<string>(() => {
return props.request.method
})
/**
* Returns the appropriate CSS class for styling the request method badge
* @param method The HTTP method
* @returns CSS class string for the method badge
*/
function getMethodClass(method: string): string {
const methodLower: string = method?.toLowerCase() || ""
switch (methodLower) {
case "get":
return "bg-green-500/10 text-green-500"
case "post":
return "bg-blue-500/10 text-blue-500"
case "put":
return "bg-orange-500/10 text-orange-500"
case "delete":
return "bg-red-500/10 text-red-500"
case "patch":
return "bg-teal-500/10 text-teal-500"
default:
return "bg-gray-500/10 text-secondaryLight"
}
}
</script>

View file

@ -0,0 +1,532 @@
<template>
<div class="flex-1 overflow-y-auto">
<div v-if="request" class="space-y-8">
<div class="flex-col space-y-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span
class="px-2 py-1 text-xs font-mono rounded"
:class="getMethodClass(requestMethod)"
>
{{ requestMethod }}
</span>
<h1 class="text-2xl font-bold text-secondaryDark">
{{ requestName }}
</h1>
</div>
<HoppSmartItem
:icon="IconExternalLink"
:title="t('documentation.open_request_in_new_tab')"
@click="openInNewTab"
/>
</div>
<div
class="text-secondaryLight text-sm break-all bg-primaryLight py-1 pl-2 rounded-md flex justify-between items-center"
>
<span>
{{ getFullEndpoint }}
</span>
<span>
<HoppSmartItem
:icon="IconCopy"
@click="copyToClipboard(getFullEndpoint)"
/>
</span>
</div>
</div>
<div class="">
<CollectionsDocumentationMarkdownEditor
v-model="editableContent"
:placeholder="t('documentation.add_request_description')"
:read-only="readOnly"
@blur="handleBlur"
/>
</div>
<!-- Check performance issue -->
<CollectionsDocumentationSectionsCurlView
:request="request"
:collection-i-d="collectionID"
:collection-path="collectionPath"
:folder-path="folderPath"
:request-index="requestIndex"
:team-i-d="teamID"
:inherited-properties="inheritedProperties"
/>
<CollectionsDocumentationSectionsAuth
:auth="request?.auth"
:inherited-auth="inheritedProperties?.auth"
/>
<CollectionsDocumentationSectionsHeaders
:headers="request?.headers || []"
:inherited-headers="inheritedProperties?.headers"
/>
<CollectionsDocumentationSectionsParameters
:params="request?.params || []"
/>
<CollectionsDocumentationSectionsVariables
:variables="request?.requestVariables || []"
/>
<CollectionsDocumentationSectionsRequestBody :body="request?.body" />
<CollectionsDocumentationSectionsResponse
:response-examples="getResponseExamples()"
/>
</div>
<div v-else class="text-center py-8 text-secondaryLight">
<icon-lucide-file-question class="mx-auto mb-2" size="32" />
<p>{{ t("documentation.no_request_data") }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import {
Environment,
HoppCollectionVariable,
HoppRESTRequest,
makeRESTRequest,
} from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { ref, computed, watch } from "vue"
import IconCopy from "~icons/lucide/copy"
import IconExternalLink from "~icons/lucide/external-link"
import { useToast } from "~/composables/toast"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { DocumentationService } from "~/services/documentation.service"
import { cascadeParentCollectionForProperties } from "~/newstore/collections"
import { cloneDeep } from "lodash-es"
import { getEffectiveRESTRequest } from "~/helpers/utils/EffectiveURL"
import { computedAsync } from "@vueuse/core"
import {
AggregateEnvironment,
getCurrentEnvironment,
} from "~/newstore/environments"
import { CurrentValueService } from "~/services/current-environment-value.service"
import { useI18n } from "~/composables/i18n"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
documentationDescription?: string
request?: HoppRESTRequest | null
collectionID?: string
collectionPath?: string | null
folderPath?: string | null
requestIndex?: number | null
requestID?: string | null
teamID?: string
readOnly?: boolean
inheritedProperties?: HoppInheritedProperty
}>(),
{
documentationDescription: "",
request: null,
collectionID: "",
collectionPath: null,
folderPath: null,
requestIndex: null,
requestID: null,
teamID: undefined,
readOnly: false,
inheritedProperties: undefined,
}
)
const emit = defineEmits<{
(event: "update:documentationDescription", value: string): void
(event: "close-modal"): void
}>()
const restTabs = useService(RESTTabService)
const teamCollectionsService = useService(TeamCollectionsService)
const documentationService = useService(DocumentationService)
const requestName = computed<string>(() => {
if (!props.request) return ""
return props.request.name || t("documentation.untitled_request")
})
const requestMethod = computed<string>(() => {
return props.request?.method || "GET"
})
const requestId = computed<string>(() => {
if (!props.request) return ""
return props.request._ref_id || (props.request as any).id || ""
})
const editMode = ref<boolean>(false)
const editableContent = ref<string>(props.documentationDescription)
const currentEnvironmentValueService = useService(CurrentValueService)
const getCurrentValue = (env: AggregateEnvironment) => {
const currentSelectedEnvironment = getCurrentEnvironment()
if (env && env.secret) {
return env.currentValue
}
return currentEnvironmentValueService.getEnvironmentByKey(
env?.sourceEnv !== "Global" ? currentSelectedEnvironment.id : "Global",
env?.key ?? ""
)?.currentValue
}
const inheritedProperties = computed(() => {
if (props.inheritedProperties) return props.inheritedProperties
if (props.teamID && props.folderPath) {
return teamCollectionsService.cascadeParentCollectionForProperties(
props.folderPath.split("/")[0]
)
}
if (props.folderPath) {
return cascadeParentCollectionForProperties(props.folderPath, "rest")
}
return undefined
})
const getEffectiveRequest = async () => {
if (!props.request) return null
let collectionVariables: HoppCollectionVariable[] = []
if (inheritedProperties.value) {
collectionVariables = inheritedProperties.value.variables.flatMap(
(v) => v.inheritedVariables
)
}
const requestVariables = (props.request.requestVariables || []).map(
(requestVariable) => {
if (requestVariable.active)
return {
key: requestVariable.key,
currentValue: requestVariable.value,
initialValue: requestVariable.value,
secret: false,
}
return {}
}
)
const env: Environment = {
v: 2,
id: "env",
name: "Env",
variables: [
...(requestVariables as Environment["variables"]),
...collectionVariables.map((e) => ({
...e,
currentValue:
getCurrentValue({
...e,
sourceEnv: "CollectionVariables",
}) || e.initialValue,
})),
],
}
const effectiveReq = await getEffectiveRESTRequest(
makeRESTRequest({
...props.request,
}),
env,
true
)
return { effectiveRequest: effectiveReq, env }
}
watch(
() => props.documentationDescription,
(newContent) => {
if (!editMode.value) {
editableContent.value = newContent
}
},
{ immediate: true }
)
function getResponseExamples() {
if (!props.request) return null
if (
props.request.responses &&
Object.keys(props.request.responses).length > 0
) {
const examples = []
for (const [name, response] of Object.entries(props.request.responses)) {
if (response && typeof response === "object") {
const example = {
name: name || "Response Example",
statusCode: response.code || 200,
headers: response.headers || [],
body: response.body || "",
contentType: "application/json",
}
examples.push(example)
}
}
return examples.length > 0 ? examples : null
}
return null
}
function handleBlur(): void {
// Only store changes in documentation service if there's actually a change
const hasChanged = editableContent.value !== props.documentationDescription
// Store changes in documentation service if request ID exists and content changed
if (hasChanged && requestId.value && props.request) {
const isTeamRequest = !!props.teamID && props.requestID
if (isTeamRequest && props.requestID) {
documentationService.setRequestDocumentation(
requestId.value,
editableContent.value,
{
parentCollectionID: props.collectionID,
isTeamItem: true,
folderPath: props.folderPath || "",
requestID: props.requestID,
teamID: props.teamID,
requestData: props.request,
}
)
} else if (
props.folderPath !== null &&
props.folderPath !== undefined &&
props.requestIndex !== null &&
props.requestIndex !== undefined
) {
documentationService.setRequestDocumentation(
requestId.value,
editableContent.value,
{
parentCollectionID: props.collectionID,
isTeamItem: false,
folderPath: props.folderPath,
requestIndex: props.requestIndex,
teamID: props.teamID,
requestData: props.request,
}
)
}
}
emit("update:documentationDescription", editableContent.value)
}
function getMethodClass(method: string): string {
const methodLower: string = method?.toLowerCase() || ""
switch (methodLower) {
case "get":
return "bg-green-500/20 text-green-500"
case "post":
return "bg-blue-500/20 text-blue-500"
case "put":
return "bg-orange-500/20 text-orange-500"
case "delete":
return "bg-red-500/20 text-red-500"
case "patch":
return "bg-teal-500/20 text-teal-500"
default:
return "bg-secondaryLight/20 text-secondaryLight"
}
}
const getFullEndpoint = computedAsync(async () => {
const res = await getEffectiveRequest()
if (!res) return ""
const { effectiveRequest } = res
if (!effectiveRequest) return ""
const input = effectiveRequest.effectiveFinalURL
if (!input) {
return "https://"
}
let url = input.trim()
url = url.replace(/^https?:\s*\/+\s*/i, (match) =>
match.toLowerCase().startsWith("https") ? "https://" : "http://"
)
if (!/^https?:\/\//i.test(url) && !url.startsWith("<<")) {
const endpoint = url
const domain = endpoint.split(/[/:#?]+/)[0]
const isLocalOrIP = /^(localhost|(\d{1,3}\.){3}\d{1,3})$/.test(domain)
url = (isLocalOrIP ? "http://" : "https://") + endpoint
}
return url
})
const copyToClipboard = async (text: string | undefined) => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
toast.success(t("documentation.copied_to_clipboard"))
} catch (err) {
console.error("Failed to copy text: ", err)
}
}
const openInNewTab = () => {
if (props.request) {
// If in read-only mode (published documentation), open external link
// for now open hoppscotch.io, we can pass the request to be opened in the future
if (props.readOnly) {
platform.kernelIO.openExternalLink({ url: "https://hoppscotch.io" })
return
}
let saveContext = null
// Determine if this is a team collection or user collection
const isTeamCollection = props.teamID && props.folderPath
if (isTeamCollection) {
saveContext = {
originLocation: "team-collection" as const,
requestID: props.requestID || requestId.value || "",
collectionID: props.folderPath!,
}
const possibleTeamTab = restTabs.getTabRefWithSaveContext(saveContext)
if (possibleTeamTab) {
restTabs.setActiveTab(possibleTeamTab.value.id)
} else {
restTabs.createNewTab({
type: "request",
request: cloneDeep(props.request),
isDirty: false,
saveContext,
inheritedProperties:
teamCollectionsService.cascadeParentCollectionForProperties(
props.folderPath!
),
})
}
} else if (props.folderPath !== null && props.requestIndex !== null) {
saveContext = {
originLocation: "user-collection" as const,
folderPath: props.folderPath,
requestIndex: props.requestIndex,
requestRefID: requestId.value,
}
const possibleUserTab = restTabs.getTabRefWithSaveContext(saveContext)
if (possibleUserTab) {
restTabs.setActiveTab(possibleUserTab.value.id)
} else {
restTabs.createNewTab({
type: "request",
request: cloneDeep(props.request),
isDirty: false,
saveContext,
inheritedProperties: cascadeParentCollectionForProperties(
props.folderPath,
"rest"
),
})
}
} else {
// Fallback: create a new tab without save context
console.warn(
"Unable to determine collection type, creating tab without save context"
)
restTabs.createNewTab({
type: "request",
request: cloneDeep(props.request),
isDirty: false,
saveContext: undefined,
inheritedProperties: undefined,
})
}
emit("close-modal")
toast.success(t("documentation.request_opened_in_new_tab"))
}
}
</script>
<style scoped>
/* CodeMirror customization */
:deep(.curl-code-mirror) {
@apply bg-primaryDark font-mono text-sm;
border: none;
}
:deep(.curl-code-mirror .CodeMirror) {
height: auto;
max-height: 200px;
@apply bg-primaryLight;
}
:deep(.curl-code-mirror .CodeMirror-scroll) {
min-height: 60px;
max-height: 200px;
}
:deep(.curl-code-mirror .cm-string) {
@apply text-green-400;
}
:deep(.curl-code-mirror .cm-variable) {
@apply text-accent;
}
:deep(.curl-code-mirror .cm-comment) {
@apply text-gray-400;
}
:deep(.curl-code-mirror .cm-attribute) {
@apply text-orange-400;
}
:deep(.curl-code-mirror .CodeMirror-gutters) {
@apply bg-primaryDark border-r border-divider;
}
/* Auto-resizing textarea */
.auto-resize-textarea {
min-height: 100px;
overflow-y: hidden;
}
/* Make textarea match its content height */
textarea {
box-sizing: border-box;
border: 1px solid transparent;
}
</style>

View file

@ -0,0 +1,945 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('documentation.title')"
:full-width-body="true"
styles="sm:max-w-6xl"
@close="hideModal"
>
<template #body>
<div class="w-full h-[80vh] overflow-hidden">
<div class="flex h-full">
<div class="flex-1 flex">
<CollectionsDocumentationPreview
v-if="currentCollection"
v-model:documentation-description="documentationDescription"
:collection="currentCollection"
:collection-i-d="collectionID"
:path-or-i-d="pathOrID"
:folder-path="folderPath"
:request-index="requestIndex"
:request-i-d="requestID"
:team-i-d="teamID"
:is-team-collection="isTeamCollection"
:all-items="allItems"
:show-all-documentation="showAllDocumentation"
:is-processing-documentation="isProcessingDocumentation"
:processing-progress="processingProgress"
:is-external-loading="loadingState || isLoadingTeamCollection"
:has-team-write-access="hasTeamWriteAccess"
@close-modal="hideModal"
@toggle-all-documentation="handleToggleAllDocumentation"
/>
<CollectionsDocumentationRequestPreview
v-else-if="request"
v-model:documentation-description="documentationDescription"
:request="request"
:collection-i-d="collectionID"
:path-or-i-d="pathOrID"
:folder-path="folderPath"
:request-index="requestIndex"
:request-i-d="requestID"
:team-i-d="teamID"
class="p-4"
@close-modal="hideModal"
/>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center w-full">
<span class="flex space-x-2">
<HoppButtonSecondary
:label="t('action.close')"
outline
filled
@click="hideModal"
/>
<HoppButtonPrimary
v-if="hasTeamWriteAccess"
:label="t('action.save')"
:loading="isSavingDocumentation"
:disabled="isSavingDocumentation"
outline
filled
@click="saveDocumentation"
/>
<!-- Publish Button - Simple button when not published -->
</span>
<div class="flex space-x-2 items-center">
<HoppButtonSecondary
v-if="
currentCollection && !isCollectionPublished && hasTeamWriteAccess
"
:icon="isCheckingPublishedStatus ? IconLoader2 : IconShare2"
:label="t('documentation.publish.button')"
:loading="isCheckingPublishedStatus"
:disabled="isCheckingPublishedStatus"
outline
filled
@click="openPublishModal"
/>
<tippy
v-else-if="
currentCollection &&
isCollectionPublished &&
!isCheckingPublishedStatus &&
hasTeamWriteAccess
"
ref="publishedDropdown"
interactive
trigger="click"
theme="popover"
:on-shown="() => publishedDropdownActions?.focus()"
>
<div
class="flex items-center border border-accent pl-4 pr-2 rounded cursor-pointer"
>
<icon-lucide-globe class="svg-icons" />
<HoppButtonSecondary
:icon="IconCheveronDown"
reverse
:label="t('documentation.publish.published')"
:loading="isCheckingPublishedStatus"
:disabled="isCheckingPublishedStatus"
class="!pr-2"
/>
</div>
<template #content="{ hide }">
<div
ref="publishedDropdownActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<div class="flex flex-col space-y-2">
<div class="flex items-center space-x-2">
<HoppSmartInput
:model-value="existingPublishedData?.url"
disabled
class="flex-1 !min-w-60"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyPublishedUrl"
/>
</div>
<HoppSmartItem
reverse
:icon="IconPenLine"
:label="t('documentation.publish.edit_published_doc')"
@click="
() => {
hide()
openPublishModal()
}
"
/>
</div>
</div>
</template>
</tippy>
<HoppButtonSecondary
:icon="isDocumentationProcessing ? IconLoader2 : IconFileText"
:label="
isDocumentationProcessing
? t('documentation.fetching_documentation')
: showAllDocumentation
? t('documentation.hide_all_documentation')
: t('documentation.show_all_documentation')
"
filled
outline
@click="handleToggleAllDocumentation"
/>
</div>
</div>
</template>
</HoppSmartModal>
<CollectionsDocumentationPublishDocModal
v-if="currentCollection && collectionID"
:show="showPublishModal"
:collection-i-d="collectionID"
:collection-title="
currentCollection.name || t('documentation.untitled_collection')
"
:workspace-type="isTeamCollection ? WorkspaceType.Team : WorkspaceType.User"
:workspace-i-d="isTeamCollection ? teamID || '' : ''"
:mode="publishModalMode"
:published-doc-id="publishedDocId"
:existing-data="existingPublishedData"
:loading="isProcessingPublish"
@hide-modal="showPublishModal = false"
@publish="handlePublish"
@update="handleUpdate"
@delete="handleDelete"
/>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onUnmounted, markRaw } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { useDocumentationWorker } from "~/composables/useDocumentationWorker"
import { useService } from "dioc/vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import {
editRESTCollection,
editRESTFolder,
editRESTRequest,
} from "~/newstore/collections"
import { updateTeamCollection } from "~/helpers/backend/mutations/TeamCollection"
import { updateTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
import {
CollectionDataProps,
getSingleTeamCollectionJSON,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import { GQLError } from "~/helpers/backend/GQLClient"
import { getErrorMessage } from "~/helpers/backend/mutations/MockServer"
import {
DocumentationService,
type DocumentationItem,
} from "~/services/documentation.service"
import IconFileText from "~icons/lucide/file-text"
import IconLoader2 from "~icons/lucide/loader-2"
import IconShare2 from "~icons/lucide/share-2"
import IconPenLine from "~icons/lucide/pen-line"
import IconCheveronDown from "~icons/lucide/chevron-down"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import {
WorkspaceType,
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
} from "~/helpers/backend/graphql"
import {
createPublishedDoc,
deletePublishedDoc,
updatePublishedDoc,
} from "~/helpers/backend/mutations/PublishedDocs"
import {
getUserPublishedDocs,
getTeamPublishedDocs,
} from "~/helpers/backend/queries/PublishedDocs"
import { TippyComponent } from "vue-tippy"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { refAutoReset, useClipboard } from "@vueuse/core"
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show?: boolean
loadingState?: boolean
hasTeamWriteAccess?: boolean
collectionID?: string
pathOrID: string | null
collection?: HoppCollection | TeamCollection | null
folderPath?: string | null
requestIndex?: number | null
requestID?: string | null
request?: HoppRESTRequest | null
teamID?: string
isTeamCollection?: boolean
}>(),
{
show: false,
loadingState: false,
hasTeamWriteAccess: true,
isTeamCollection: false,
collectionID: "",
collection: null,
folderPath: null,
requestIndex: null,
requestID: null,
request: null,
teamID: undefined,
}
)
const documentationService = useService(DocumentationService)
const isLoadingTeamCollection = ref<boolean>(false)
const isSavingDocumentation = ref<boolean>(false)
const isCheckingPublishedStatus = ref<boolean>(false)
const isProcessingPublish = ref<boolean>(false)
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
const { copy } = useClipboard()
const allItems = ref<Array<any>>([])
const showAllDocumentation = ref<boolean>(false)
const showPublishModal = ref<boolean>(false)
const publishedDropdown = ref<TippyComponent | null>(null)
const publishedDropdownActions = ref<HTMLDivElement | null>(null)
// Published docs state
const isCollectionPublished = ref<boolean>(false)
const publishedDocId = ref<string | undefined>(undefined)
const existingPublishedData = ref<
| {
title: string
version: string
autoSync: boolean
url: string
}
| undefined
>(undefined)
const publishModalMode = computed<"create" | "update" | "view">(() => {
return isCollectionPublished.value ? "update" : "create"
})
const isDocumentationProcessing = computed(() => {
return isProcessingDocumentation.value || isLoadingTeamCollection.value
})
const {
isProcessing: isProcessingDocumentation,
progress: processingProgress,
processDocumentation,
} = useDocumentationWorker()
// Store the full collection data with all nested folders and requests
const fullCollectionData = ref<HoppCollection | null>(null)
const fetchTeamCollection = async () => {
if (!props.isTeamCollection || !props.collection?.id || !props.teamID) {
return
}
isLoadingTeamCollection.value = true
try {
const data = await getSingleTeamCollectionJSON(
props.teamID,
props.collection.id
)
if (data && E.isRight(data)) {
const parsedCollection = JSON.parse(data.right)
fullCollectionData.value = parsedCollection
} else {
fullCollectionData.value = teamCollToHoppRESTColl(
props.collection as TeamCollection
)
}
} catch (error) {
fullCollectionData.value = teamCollToHoppRESTColl(
props.collection as TeamCollection
)
} finally {
isLoadingTeamCollection.value = false
}
}
// Get the current collection - use fetched data only after toggleAllDocumentation is clicked
const currentCollection = computed<HoppCollection | null>(() => {
if (!props.collection) return null
// For team collections, use the full collection data only if available (after toggle)
if (props.isTeamCollection && fullCollectionData.value) {
return fullCollectionData.value
}
if (props.isTeamCollection) {
return teamCollToHoppRESTColl(props.collection as TeamCollection)
}
// Use the prop collection by default
return props.collection as HoppCollection
})
// Handle toggle all documentation - process items in parent
const handleToggleAllDocumentation = async () => {
if (!showAllDocumentation.value) {
// For team collections, fetch latest collection data first
if (props.isTeamCollection && props.collection?.id && props.teamID) {
await fetchTeamCollection()
await nextTick() // Wait for collection to update
}
const collectionToProcess = currentCollection.value
if (!collectionToProcess) {
return
}
try {
// Process documentation in parent
const items = await processDocumentation(
collectionToProcess as HoppCollection,
props.pathOrID,
props.isTeamCollection
)
// Set processed items and toggle state - child will react automatically
allItems.value = items
showAllDocumentation.value = true
} catch (error) {
console.error("Error processing documentation:", error)
allItems.value = []
showAllDocumentation.value = false
}
} else {
// Hide all documentation - child will react automatically
showAllDocumentation.value = false
allItems.value = []
}
}
// Check for existing published docs status
const checkPublishedDocsStatus = async () => {
if (!props.collectionID) return
isCheckingPublishedStatus.value = true
isCollectionPublished.value = false
publishedDocId.value = undefined
existingPublishedData.value = undefined
// Check if collection is already published
if (props.isTeamCollection && props.teamID) {
await pipe(
getTeamPublishedDocs(props.teamID, props.collectionID),
TE.match(
(error) => {
console.log("No published docs found or error:", error)
isCheckingPublishedStatus.value = false
},
(docs) => {
// Find published doc for this collection
const publishedDoc = docs.find(
(doc) => doc.collection.id === props.collectionID
)
if (publishedDoc) {
isCollectionPublished.value = true
publishedDocId.value = publishedDoc.id
existingPublishedData.value = {
title: publishedDoc.title,
version: publishedDoc.version,
autoSync: publishedDoc.autoSync,
url: publishedDoc.url,
}
}
isCheckingPublishedStatus.value = false
}
)
)()
} else {
await pipe(
getUserPublishedDocs(),
TE.match(
(error) => {
console.log("No published docs found or error:", error)
isCheckingPublishedStatus.value = false
},
(docs) => {
console.log("//published-docs///", docs)
// Find published doc for this collection
const publishedDoc = docs.find(
(doc) => doc.collection.id === props.collectionID
)
if (publishedDoc) {
isCollectionPublished.value = true
publishedDocId.value = publishedDoc.id
existingPublishedData.value = {
title: publishedDoc.title,
version: publishedDoc.version,
autoSync: publishedDoc.autoSync,
url: publishedDoc.url,
}
}
isCheckingPublishedStatus.value = false
}
)
)()
}
}
// Reset fetched collection data when modal opens/closes
watch(
() => props.show,
async (newVal) => {
console.log("///show///", newVal)
if (newVal) {
console.log("//check-published-docs-status///")
// Check for existing published docs when modal opens
await checkPublishedDocsStatus()
} else {
// Reset when modal closes
fullCollectionData.value = null
isLoadingTeamCollection.value = false
// Clear all processed items
allItems.value = []
showAllDocumentation.value = false
// Clear documentation service changes
documentationService.clearAll()
}
},
{
immediate: true,
}
)
// Ensure cleanup when component is unmounted
onUnmounted(() => {
documentationService.clearAll()
})
const documentationDescription = ref<string>("")
watch(
() => currentCollection.value,
(newCollection) => {
if (newCollection) {
documentationDescription.value = newCollection.description || ""
} else if (props.request) {
documentationDescription.value = props.request.description || ""
} else {
documentationDescription.value = ""
}
},
{ immediate: true }
)
const openPublishModal = () => {
showPublishModal.value = true
}
const copyPublishedUrl = () => {
if (existingPublishedData.value?.url) {
copyIcon.value = markRaw(IconCheck)
copy(existingPublishedData.value.url)
toast.success(t("documentation.publish.url_copied"))
}
}
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update:modelValue"): void
}>()
const saveDocumentation = async () => {
if (!props.hasTeamWriteAccess) {
toast.error(t("documentation.no_write_access"))
return
}
try {
// Get all changed items from documentation service
const changedItems = documentationService.getChangedItems()
if (changedItems.length === 0 && !showAllDocumentation.value) {
// No changes to save, but save current documentation description if exists
if (currentCollection.value && props.pathOrID) {
await saveCollectionDocumentation()
} else if (
props.request &&
props.folderPath !== undefined &&
props.folderPath !== null &&
props.requestIndex !== undefined &&
props.requestIndex !== null
) {
await saveRequestDocumentation()
}
} else {
// Save all changed items from documentation service
const results: boolean[] = []
for (const item of changedItems) {
if (item.type === "collection") {
const saveCollectionResult =
await saveCollectionDocumentationById(item)
results.push(saveCollectionResult)
} else if (item.type === "request") {
const saveRequestResult = await saveRequestDocumentationById(item)
results.push(saveRequestResult)
}
}
const successCount = results.filter((r) => r).length
const failureCount = results.length - successCount
if (failureCount === 0) {
toast.success(t("documentation.save_success"))
} else if (successCount === 0) {
toast.error(t("documentation.save_error"))
} else {
toast.success(
t("documentation.saved_items_status", {
success: successCount,
failure: failureCount,
})
)
}
// Clear all changes after successful save
documentationService.clearAll()
}
} catch (error) {
console.error("Error saving documentation:", error)
toast.error(t("documentation.save_error"))
}
}
const saveCollectionDocumentation = async () => {
const collection = currentCollection.value!
if (props.isTeamCollection) {
// Set loading state for team operations only
isSavingDocumentation.value = true
// Team collection data
const data: CollectionDataProps = {
auth: collection.auth || { authType: "inherit", authActive: true },
headers: collection.headers || [],
variables: collection.variables || [],
description: documentationDescription.value,
}
pipe(
updateTeamCollection(collection.id!, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
isSavingDocumentation.value = false
},
() => {
toast.success(t("documentation.save_success"))
isSavingDocumentation.value = false
}
)
)()
} else {
// Personal collection (no loading state)
const updatedCollection = {
...collection,
description: documentationDescription.value,
}
// Check if this is a root collection or a folder
const pathSegments = props.pathOrID!.split("/")
if (pathSegments.length === 1) {
editRESTCollection(parseInt(props.pathOrID!), updatedCollection)
} else {
editRESTFolder(props.pathOrID!, updatedCollection)
}
toast.success(t("documentation.save_success"))
}
}
const saveRequestDocumentation = async () => {
const updatedRequest = {
...props.request!,
description: documentationDescription.value,
}
if (props.isTeamCollection && props.requestID) {
// Set loading state for team operations only
isSavingDocumentation.value = true
const data = {
request: JSON.stringify(updatedRequest),
title: updatedRequest.name,
}
pipe(
updateTeamRequest(props.requestID!, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
isSavingDocumentation.value = false
},
() => {
toast.success(t("documentation.save_success"))
isSavingDocumentation.value = false
}
)
)()
} else {
// Personal request
editRESTRequest(props.folderPath!, props.requestIndex!, updatedRequest)
toast.success(t("documentation.save_success"))
}
}
// Save collection documentation by ID
const saveCollectionDocumentationById = async (
item: DocumentationItem
): Promise<boolean> => {
// Type guard to ensure it's a collection item
if (item.type !== "collection") return false
const {
id: collectionId,
documentation,
isTeamItem,
pathOrID,
collectionData,
} = item
if (isTeamItem) {
// Set loading state for team operations only
isSavingDocumentation.value = true
// Use the stored collection data from the service
if (collectionData) {
const data: CollectionDataProps = {
auth: collectionData.auth || { authType: "inherit", authActive: true },
headers: collectionData.headers || [],
variables: collectionData.variables || [],
description: documentation,
}
const result = await pipe(
updateTeamCollection(collectionId, data),
TE.match(
(err: GQLError<string>) => {
console.error(getErrorMessage(err))
return false
},
() => {
return true
}
)
)()
isSavingDocumentation.value = false
return result
}
console.error("Collection data not found in service")
isSavingDocumentation.value = false
return false
}
if (pathOrID && collectionData) {
const updatedCollection = {
...collectionData,
description: documentation,
}
// Check if this is a root collection or a folder
const pathSegments = pathOrID.split("/")
try {
if (pathSegments.length === 1) {
editRESTCollection(parseInt(pathOrID), updatedCollection)
} else {
editRESTFolder(pathOrID, updatedCollection)
}
return true
} catch (e) {
console.error(e)
return false
}
} else {
console.error("Collection path or data not found")
return false
}
}
// Save request documentation by ID
const saveRequestDocumentationById = async (
item: DocumentationItem
): Promise<boolean> => {
if (item.type !== "request") return false
const { documentation, isTeamItem, folderPath, requestData } = item
if (isTeamItem) {
// Set loading state for team operations only
isSavingDocumentation.value = true
// For team requests, check if requestID exists
if (requestData && item.requestID) {
const updatedRequest = {
...requestData,
description: documentation,
}
const data = {
request: JSON.stringify(updatedRequest),
title: updatedRequest.name,
}
const result = await pipe(
updateTeamRequest(item.requestID, data),
TE.match(
(err: GQLError<string>) => {
console.error(getErrorMessage(err))
return false
},
() => {
return true
}
)
)()
isSavingDocumentation.value = false
return result
}
console.error("Team request data not found in service")
isSavingDocumentation.value = false
return false
}
if (
folderPath !== undefined &&
item.requestIndex !== undefined &&
requestData
) {
const updatedRequest = {
...requestData,
description: documentation,
}
try {
editRESTRequest(folderPath, item.requestIndex, updatedRequest)
return true
} catch (e) {
console.error(e)
return false
}
} else {
console.error("Personal request data not found in service")
return false
}
}
// Used to prevent accidental closing of the modal with unsaved changes
const closeAttempted = ref(false)
let closeTimeout: ReturnType<typeof setTimeout> | null = null
const hideModal = () => {
if (documentationService.hasChanges.value && !closeAttempted.value) {
// Get the number of unsaved changes
const unsavedChangesLength = documentationService.getChangedItems().length
closeAttempted.value = true
toast.info(
t("documentation.unsaved_changes", { count: unsavedChangesLength }),
{
action: {
text: t("action.close"),
onClick: (_, toastObject) => {
toastObject.goAway(0)
closeAttempted.value = false
emit("hide-modal")
},
},
}
)
if (closeTimeout) clearTimeout(closeTimeout)
closeTimeout = setTimeout(() => {
closeAttempted.value = false
}, 3000)
return
}
emit("hide-modal")
closeAttempted.value = false
if (closeTimeout) clearTimeout(closeTimeout)
}
const handlePublish = async (doc: CreatePublishedDocsArgs) => {
isProcessingPublish.value = true
await pipe(
createPublishedDoc(doc),
TE.match(
(error) => {
console.error("Error publishing documentation:", error)
toast.error(t("documentation.publish.publish_error"))
},
(data) => {
const url = data.createPublishedDoc.url
toast.success(t("documentation.publish.publish_success"))
// Update state
isCollectionPublished.value = true
publishedDocId.value = data.createPublishedDoc.id
existingPublishedData.value = {
title: doc.title,
version: doc.version,
autoSync: doc.autoSync,
url: url,
}
}
)
)()
isProcessingPublish.value = false
}
const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => {
isProcessingPublish.value = true
await pipe(
updatePublishedDoc(id, doc),
TE.match(
(error) => {
console.error("Error updating documentation:", error)
toast.error(t("documentation.publish.update_error"))
},
(data) => {
const url = data.updatePublishedDoc.url
toast.success(t("documentation.publish.update_success"))
// Update existing data
if (existingPublishedData.value) {
existingPublishedData.value = {
title: data.updatePublishedDoc.title,
version: data.updatePublishedDoc.version,
autoSync: data.updatePublishedDoc.autoSync,
url: url,
}
}
}
)
)()
isProcessingPublish.value = false
}
const handleDelete = async () => {
if (!publishedDocId.value) return
isProcessingPublish.value = true
await pipe(
deletePublishedDoc(publishedDocId.value),
TE.match(
(error) => {
console.error("Error deleting documentation:", error)
toast.error(t("documentation.publish.delete_error"))
},
() => {
toast.success(t("documentation.publish.delete_success"))
isCollectionPublished.value = false
publishedDocId.value = undefined
existingPublishedData.value = undefined
showPublishModal.value = false
}
)
)()
isProcessingPublish.value = false
}
</script>

View file

@ -0,0 +1,512 @@
<template>
<div
v-if="
effectiveAuth &&
effectiveAuth.authActive &&
effectiveAuth.authType !== 'none' &&
effectiveAuth.authType !== 'inherit'
"
class="max-w-2xl space-y-2"
>
<h2
class="text-sm font-semibold text-secondaryDark flex items-center px-4 p-2 border-b border-divider"
>
<span>{{ t("documentation.auth.title") }}</span>
<span
v-if="auth?.authType === 'inherit'"
class="ml-2 font-semibold capitalize px-2 py-1 text-tiny rounded bg-divider text-secondaryDark"
>
({{
t("documentation.inherited_with_type", {
name: inheritedAuth?.parentName,
type: inheritedAuth?.inheritedAuth.authType,
})
}})
</span>
</h2>
<div class="px-4 py-2 flex flex-col">
<div class="space-y-3">
<!-- Basic Auth -->
<div v-if="effectiveAuth.authType === 'basic'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.username")
}}</span>
<span class="px-1">{{
effectiveAuth.username || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.password")
}}</span>
<span class="px-1"></span>
</div>
</div>
<!-- Bearer Token -->
<div v-if="effectiveAuth.authType === 'bearer'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.bearer_token")
}}</span>
<span class="px-1"></span>
</div>
</div>
<!-- API Key -->
<div v-if="effectiveAuth.authType === 'api-key'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.key")
}}</span>
<span class="px-1">{{
effectiveAuth.key || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.value")
}}</span>
<span class="px-1">{{
effectiveAuth.value ? "••••••••" : t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.add_to")
}}</span>
<span
class="px-2 py-0.5 text-xs rounded bg-divider text-secondaryDark"
>
{{ effectiveAuth.addTo || "headers" }}
</span>
</div>
</div>
<!-- OAuth 2.0 -->
<div v-if="effectiveAuth.authType === 'oauth-2'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.grant_type")
}}</span>
<span class="px-1">{{
getGrantType() || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.auth_url")
}}</span>
<span class="px-1 text-xs break-all">{{
getAuthEndpoint() || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.token_url")
}}</span>
<span class="px-1 text-xs break-all">{{
getTokenEndpoint() || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.client_id")
}}</span>
<span class="px-1">{{
getClientId() || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.client_secret")
}}</span>
<span class="px-1">{{
hasClientSecret() ? "••••••••" : t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.scope")
}}</span>
<span class="px-1">{{
getScopes() || t("documentation.not_set")
}}</span>
</div>
</div>
</div>
<!-- Digest Auth -->
<div v-if="effectiveAuth.authType === 'digest'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.username")
}}</span>
<span class="px-1">{{
effectiveAuth.username || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.password")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.realm")
}}</span>
<span class="px-1">{{
effectiveAuth.realm || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.nonce")
}}</span>
<span class="px-1">{{
effectiveAuth.nonce || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.algorithm")
}}</span>
<span class="px-1">{{ effectiveAuth.algorithm || "MD5" }}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.qop")
}}</span>
<span class="px-1">{{ effectiveAuth.qop || "auth" }}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.client_nonce")
}}</span>
<span class="px-1">{{
effectiveAuth.cnonce || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.opaque")
}}</span>
<span class="px-1">{{
effectiveAuth.opaque || t("documentation.not_set")
}}</span>
</div>
</div>
<!-- AWS Signature -->
<div v-if="effectiveAuth.authType === 'aws-signature'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.access_key")
}}</span>
<span class="px-1">{{
effectiveAuth.accessKey || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.secret_key")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.region")
}}</span>
<span class="px-1">{{
effectiveAuth.region || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.service_name")
}}</span>
<span class="px-1">{{
effectiveAuth.serviceName || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.add_to")
}}</span>
<span
class="px-2 py-0.5 text-xs rounded bg-divider text-secondaryDark"
>
{{ effectiveAuth.addTo || "HEADERS" }}
</span>
</div>
</div>
<!-- HAWK Auth -->
<div v-if="effectiveAuth.authType === 'hawk'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.auth_id")
}}</span>
<span class="px-1">{{
effectiveAuth.authId || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.auth_key")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.algorithm")
}}</span>
<span class="px-1">{{ effectiveAuth.algorithm || "sha256" }}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.user")
}}</span>
<span class="px-1">{{
effectiveAuth.user || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.nonce")
}}</span>
<span class="px-1">{{
effectiveAuth.nonce || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.extra_data")
}}</span>
<span class="px-1">{{
effectiveAuth.ext || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.app_id")
}}</span>
<span class="px-1">{{
effectiveAuth.app || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.delegation")
}}</span>
<span class="px-1">{{
effectiveAuth.dlg || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.timestamp")
}}</span>
<span class="px-1">{{
effectiveAuth.timestamp || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.include_payload_hash")
}}</span>
<span class="px-1">{{
effectiveAuth.includePayloadHash
? t("documentation.yes")
: t("documentation.no")
}}</span>
</div>
</div>
<!-- Akamai EdgeGrid -->
<div v-if="effectiveAuth.authType === 'akamai-eg'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.access_token")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.client_token")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.client_secret")
}}</span>
<span class="px-1"></span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.nonce")
}}</span>
<span class="px-1">{{
effectiveAuth.nonce || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.timestamp")
}}</span>
<span class="px-1">{{
effectiveAuth.timestamp || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.host")
}}</span>
<span class="px-1">{{
effectiveAuth.host || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.headers_to_sign")
}}</span>
<span class="px-1">{{
effectiveAuth.headersToSign || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.max_body_size")
}}</span>
<span class="px-1">{{
effectiveAuth.maxBodySize || t("documentation.not_set")
}}</span>
</div>
</div>
<!-- JWT Auth -->
<div v-if="effectiveAuth.authType === 'jwt'" class="space-y-2">
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.algorithm")
}}</span>
<span class="px-1">{{ effectiveAuth.algorithm || "HS256" }}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.payload")
}}</span>
<span class="px-1 break-all">{{
effectiveAuth.payload || t("documentation.not_set")
}}</span>
</div>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
t("documentation.auth.add_to")
}}</span>
<span
class="px-2 py-0.5 text-xs rounded bg-divider text-secondaryDark"
>
{{ effectiveAuth.addTo || "HEADERS" }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTAuth, HoppGQLAuth } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
import { computed } from "vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
const t = useI18n()
const props = defineProps<{
auth: HoppRESTAuth | HoppGQLAuth | null | undefined
inheritedAuth?: HoppInheritedProperty["auth"]
}>()
const effectiveAuth = computed(() => {
if (
props.auth?.authType === "inherit" &&
props.inheritedAuth?.inheritedAuth
) {
return props.inheritedAuth.inheritedAuth
}
return props.auth
})
// Helper functions for OAuth 2.0
const getGrantType = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo
) {
return effectiveAuth.value.grantTypeInfo.grantType
}
return undefined
}
const getAuthEndpoint = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo
) {
return effectiveAuth.value.grantTypeInfo.authEndpoint
}
return undefined
}
const getTokenEndpoint = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo &&
"tokenEndpoint" in effectiveAuth.value.grantTypeInfo
) {
return effectiveAuth.value.grantTypeInfo.tokenEndpoint
}
return undefined
}
const getClientId = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo
) {
return effectiveAuth.value.grantTypeInfo.clientID
}
return undefined
}
const hasClientSecret = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo &&
"clientSecret" in effectiveAuth.value.grantTypeInfo
) {
return !!effectiveAuth.value.grantTypeInfo.clientSecret
}
return false
}
const getScopes = () => {
if (
effectiveAuth.value?.authType === "oauth-2" &&
"grantTypeInfo" in effectiveAuth.value &&
effectiveAuth.value.grantTypeInfo
) {
return effectiveAuth.value.grantTypeInfo.scopes
}
return undefined
}
</script>

View file

@ -0,0 +1,550 @@
<template>
<div v-if="request" ref="curlViewContainer" class="space-y-2">
<div class="rounded-md border border-divider overflow-hidden">
<div
class="flex items-center justify-between p-2 bg-divider/30 border-b border-divider"
>
<div class="text-sm font-medium text-secondaryDark flex items-center">
<icon-lucide-terminal class="mr-2" size="14" />
{{ t("documentation.curl.title") }}
</div>
<div class="flex items-center space-x-2">
<div>
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions?.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
:label="
CodegenDefinitions.find((x) => x.name === codegenType)!
.caption
"
outline
class="flex-1 pr-8"
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div class="flex flex-col space-y-2">
<div class="sticky top-0 z-10 flex-shrink-0 overflow-x-auto">
<input
v-model="searchQuery"
type="search"
autocomplete="off"
class="input flex w-full !bg-primaryContrast p-4 py-2"
:placeholder="`${t('action.search')}`"
/>
</div>
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="codegen in filteredCodegenDefinitions"
:key="codegen.name"
:label="codegen.caption"
:info-icon="
codegen.name === codegenType ? IconCheck : undefined
"
:active-info-icon="codegen.name === codegenType"
@click="
() => {
codegenType = codegen.name
codegenMode = codegen.lang
hide()
}
"
/>
<HoppSmartPlaceholder
v-if="
searchQuery.length !== 0 &&
filteredCodegenDefinitions.length === 0
"
:text="`${t('state.nothing_found')} ‟${searchQuery}”`"
>
<template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
</div>
</div>
</template>
</tippy>
</div>
<div class="flex space-x-2">
<HoppSmartItem
:icon="curlCopied ? IconCheck : IconCopy"
:title="t('documentation.curl.copy_to_clipboard')"
@click="copyCurlCommand"
/>
</div>
</div>
</div>
<div class="curl-editor flex-1 w-full p-4 overflow-auto">
<!-- Lazy load the code view -->
<div v-if="!isVisible" class="text-secondary text-center py-8">
<div class="text-sm">{{ t("documentation.curl.click_to_load") }}</div>
<HoppButtonSecondary
:label="t('documentation.curl.load')"
class="mt-2"
@click="loadCurl"
/>
</div>
<pre
v-else-if="highlightedCode"
ref="curlCodeContainer"
class="hljs-container max-w-[35rem]"
><code
class="hljs language-bash break-words overflow-auto"
v-html="highlightedCode"
></code></pre>
<!-- XSS Note: highlightedCode is safely generated by highlight.js library -->
<div v-else-if="isLoading" class="text-secondary text-center py-4">
<div class="animate-pulse">
{{ t("documentation.curl.generating") }}
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
Environment,
HoppCollectionVariable,
HoppRESTRequest,
makeRESTRequest,
} from "@hoppscotch/data"
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useIntersectionObserver } from "@vueuse/core"
import {
CodegenDefinitions,
CodegenLang,
CodegenName,
generateCode,
} from "~/helpers/new-codegen"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import { useI18n } from "~/composables/i18n"
import * as O from "fp-ts/Option"
import { useToast } from "~/composables/toast"
import { useService } from "dioc/vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import {
getEffectiveRESTRequest,
resolvesEnvsInBody,
} from "~/helpers/utils/EffectiveURL"
import {
AggregateEnvironment,
getCurrentEnvironment,
} from "~/newstore/environments"
import { CurrentValueService } from "~/services/current-environment-value.service"
import hljs from "highlight.js/lib/core"
import javascript from "highlight.js/lib/languages/javascript"
import python from "highlight.js/lib/languages/python"
import php from "highlight.js/lib/languages/php"
import go from "highlight.js/lib/languages/go"
import ruby from "highlight.js/lib/languages/ruby"
import java from "highlight.js/lib/languages/java"
import csharp from "highlight.js/lib/languages/csharp"
import powershell from "highlight.js/lib/languages/powershell"
import http from "highlight.js/lib/languages/http"
import r from "highlight.js/lib/languages/r"
import rust from "highlight.js/lib/languages/rust"
import swift from "highlight.js/lib/languages/swift"
import kotlin from "highlight.js/lib/languages/kotlin"
import clojure from "highlight.js/lib/languages/clojure"
import hljsCurl from "highlightjs-curl"
// Import highlight.js CSS for base styling
import "highlight.js/styles/github-dark.css"
// Initialize highlight.js languages only once globally
const initializeHighlightJS = (() => {
let initialized = false
return () => {
if (initialized) return
initialized = true
// Register all languages for highlight.js
hljs.registerLanguage("bash", hljsCurl)
hljs.registerLanguage("shell", hljsCurl)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("python", python)
hljs.registerLanguage("php", php)
hljs.registerLanguage("go", go)
hljs.registerLanguage("ruby", ruby)
hljs.registerLanguage("java", java)
hljs.registerLanguage("csharp", csharp)
hljs.registerLanguage("powershell", powershell)
hljs.registerLanguage("http", http)
hljs.registerLanguage("r", r)
hljs.registerLanguage("rust", rust)
hljs.registerLanguage("swift", swift)
hljs.registerLanguage("kotlin", kotlin)
hljs.registerLanguage("clojure", clojure)
hljs.registerLanguage("c", hljsCurl)
hljs.registerLanguage("objc", hljsCurl)
hljs.registerLanguage("ocaml", hljsCurl)
}
})()
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
request?: HoppRESTRequest | null
collectionID?: string
collectionPath?: string | null
folderPath?: string | null
requestIndex?: number | null
teamID?: string
inheritedProperties?: HoppInheritedProperty
}>(),
{
request: null,
collectionID: "",
collectionPath: null,
folderPath: null,
requestIndex: null,
teamID: undefined,
}
)
const curlCodeContainer = ref<HTMLElement | null>(null)
const curlViewContainer = ref<HTMLElement | null>(null)
const curlCopied = ref<boolean>(false)
const codegenType = ref<CodegenName>("shell-curl")
const codegenMode = ref<CodegenLang>("shell")
const isVisible = ref(false)
const isLoading = ref(false)
const tippyActions = ref<HTMLElement | null>(null)
const searchQuery = ref("")
// Set up intersection observer to auto-load cURL when component enters viewport
const { stop } = useIntersectionObserver(
curlViewContainer,
([{ isIntersecting }]) => {
if (isIntersecting && !isVisible.value && !isLoading.value) {
loadCurl()
}
},
{
threshold: 0.1,
rootMargin: "50px",
}
)
const filteredCodegenDefinitions = computed(() => {
return CodegenDefinitions.filter((obj) =>
Object.values(obj).some((val) =>
val.toLowerCase().includes(searchQuery.value.toLowerCase())
)
)
})
const currentEnvironmentValueService = useService(CurrentValueService)
const getCurrentValue = (env: AggregateEnvironment) => {
const currentSelectedEnvironment = getCurrentEnvironment()
let value: string | undefined
if (env && env.secret) {
value = env.currentValue
} else {
value = currentEnvironmentValueService.getEnvironmentByKey(
env?.sourceEnv !== "Global" ? currentSelectedEnvironment.id : "Global",
env?.key ?? ""
)?.currentValue
}
return value
}
const getFinalURL = (input: string): string => {
if (!input) {
return "https://"
}
let url = input.trim()
url = url.replace(/^https?:\s*\/+\s*/i, (match) =>
match.toLowerCase().startsWith("https") ? "https://" : "http://"
)
if (!/^https?:\/\//i.test(url) && !url.startsWith("<<")) {
const endpoint = url
const domain = endpoint.split(/[/:#?]+/)[0]
const isLocalOrIP = /^(localhost|(\d{1,3}\.){3}\d{1,3})$/.test(domain)
url = (isLocalOrIP ? "http://" : "https://") + endpoint
}
return url
}
const getEffectiveRequest = async () => {
if (!props.request) return null
let collectionVariables: HoppCollectionVariable[] = []
if (props.inheritedProperties) {
collectionVariables = props.inheritedProperties.variables.flatMap(
(parentVar) =>
parentVar.inheritedVariables.map((variable) => ({
key: variable.key,
initialValue: variable.initialValue,
currentValue: variable.currentValue,
secret: variable.secret,
}))
)
}
const requestVariables = props.request.requestVariables.map(
(requestVariable) => {
if (requestVariable.active)
return {
key: requestVariable.key,
currentValue: requestVariable.value,
initialValue: requestVariable.value,
secret: false,
}
return {}
}
)
const env: Environment = {
v: 2,
id: "env",
name: "Env",
variables: [
...(requestVariables as Environment["variables"]),
...collectionVariables.map((envVar) => ({
...envVar,
currentValue:
getCurrentValue({
...envVar,
sourceEnv: "Global",
} as AggregateEnvironment) || envVar.initialValue,
})),
],
}
const effectiveReq = await getEffectiveRESTRequest(
makeRESTRequest({
...props.request,
}),
env,
true
)
const result = { effectiveRequest: effectiveReq, env }
return result
}
// Lazy computed for cURL command
const curlCommand = ref<string>("")
const generateCurlCommand = async () => {
if (!props.request) {
curlCommand.value = "# No request data available"
return
}
isLoading.value = true
try {
const lang = codegenType.value
const res = await getEffectiveRequest()
if (!res) {
curlCommand.value = ""
return
}
const { effectiveRequest, env } = res
const result = generateCode(
lang,
makeRESTRequest({
...effectiveRequest,
body: resolvesEnvsInBody(effectiveRequest.body, env),
headers: effectiveRequest.effectiveFinalHeaders.map((header: any) => ({
...header,
active: true,
})),
params: effectiveRequest.effectiveFinalParams.map((param: any) => ({
...param,
active: true,
})),
endpoint: getFinalURL(effectiveRequest.effectiveFinalURL),
requestVariables: effectiveRequest.effectiveFinalRequestVariables.map(
(requestVariable: any) => ({
...requestVariable,
active: true,
})
),
})
)
if (O.isSome(result)) {
curlCommand.value = result.value
} else {
curlCommand.value = ""
}
} catch (error) {
console.error("Error generating cURL command:", error)
curlCommand.value = "# Error generating cURL command"
} finally {
isLoading.value = false
}
}
const highlightedCode = computed(() => {
if (!curlCommand.value || !isVisible.value) return ""
try {
// Map codegen language to highlight.js language
const languageMap: Record<string, string> = {
shell: "bash",
javascript: "javascript",
python: "python",
php: "php",
go: "go",
ruby: "ruby",
java: "java",
csharp: "csharp",
powershell: "powershell",
http: "http",
r: "r",
rust: "rust",
swift: "swift",
kotlin: "kotlin",
clojure: "clojure",
c: "bash",
objc: "bash",
ocaml: "bash",
}
const language = languageMap[codegenMode.value] || "bash"
const highlighted = hljs.highlight(curlCommand.value, {
language,
ignoreIllegals: true,
})
return highlighted.value
} catch (error) {
console.error("Syntax highlighting failed:", error)
// Fallback to auto-detection
try {
const autoHighlighted = hljs.highlightAuto(curlCommand.value, [
"bash",
"shell",
])
return autoHighlighted.value
} catch (autoError) {
console.error("Auto highlighting also failed:", autoError)
// Final fallback to plain text with HTML escaping
return curlCommand.value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
}
})
// Load cURL command when needed
const loadCurl = async () => {
if (!isVisible.value) {
isVisible.value = true
await generateCurlCommand()
}
}
// Watch for codegen type changes and regenerate
watch([codegenType, codegenMode], () => {
if (isVisible.value) {
generateCurlCommand()
}
})
const copyCurlCommand = async () => {
if (!curlCommand.value) return
try {
await navigator.clipboard.writeText(curlCommand.value)
curlCopied.value = true
setTimeout(() => {
curlCopied.value = false
}, 2000)
toast.success(t("documentation.curl.copied"))
} catch (err) {
console.error("Failed to copy cURL command: ", err)
}
}
// Initialize highlight.js once
onMounted(() => {
initializeHighlightJS()
})
onUnmounted(() => {
// Stop intersection observer
stop()
})
</script>
<style scoped>
/* Override highlight.js theme colors to match Hoppscotch theme */
:deep(.hljs) {
background: rgb(var(--primary-light-color)) !important;
color: rgb(var(--secondary-dark-color)) !important;
padding: 0;
margin: 0;
overflow: visible;
display: block;
line-height: 1.5;
}
:deep(.hljs-container) {
background-color: rgb(var(--primary-light-color));
border-radius: 0;
margin: 0;
padding: 0;
}
/* for block of numbers */
.hljs-ln-numbers {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-align: center;
color: #ccc;
border-right: 1px solid #ccc;
vertical-align: top;
padding-right: 5px;
/* your custom style here */
}
/* for block of code */
.hljs-ln-code {
padding-left: 10px;
}
</style>

View file

@ -0,0 +1,102 @@
<template>
<div
v-if="hasItems(headers) || hasItems(inheritedHeaders)"
class="w-full space-y-2"
>
<h2
class="text-sm font-semibold text-secondaryDark flex items-end px-4 p-2 border-b border-divider"
>
<span>{{ t("documentation.headers.title") }}</span>
</h2>
<div class="px-4 py-2 flex flex-col">
<div class="space-y-3">
<div
class="grid grid-cols-[8rem_16rem_1fr_12rem] gap-4 border-b border-divider pb-2 text-secondaryDark font-semibold items-center"
>
<span class="truncate">{{ t("documentation.key") }}</span>
<span class="truncate">{{ t("documentation.value") }}</span>
<span class="truncate">{{ t("documentation.description") }}</span>
<span class="truncate"></span>
</div>
<template v-if="inheritedHeaders">
<!-- Inherited Headers -->
<div
v-for="(header, index) in inheritedHeaders"
:key="`inherited-${index}`"
class="grid grid-cols-[8rem_16rem_1fr_12rem] gap-4 items-center"
>
<span
class="font-medium text-secondaryDark truncate"
:title="header.inheritedHeader.key"
>{{ header.inheritedHeader.key }}</span
>
<span class="truncate" :title="header.inheritedHeader.value">{{
header.inheritedHeader.value
}}</span>
<span
class="text-xs text-secondaryLight truncate"
:title="header.inheritedHeader.description"
>
{{ header.inheritedHeader.description }}
</span>
<div class="truncate">
<span class="text-tiny text-secondaryLight">
{{
t("documentation.inherited_from", { name: header.parentName })
}}
</span>
</div>
</div>
</template>
<!-- Request Headers -->
<div
v-for="(header, index) in headers"
:key="index"
class="grid grid-cols-[8rem_16rem_1fr_12rem] gap-4 items-center"
>
<span
class="font-medium text-secondaryDark truncate"
:title="header.key"
>{{ header.key }}</span
>
<span class="truncate" :title="header.value">{{ header.value }}</span>
<span
class="text-xs text-secondaryLight truncate"
:title="header.description"
>
{{ header.description }}
</span>
<div></div>
</div>
<div
v-if="
headers.length === 0 &&
(!inheritedHeaders || inheritedHeaders.length === 0)
"
class="text-secondaryLight text-sm py-1"
>
{{ t("documentation.headers.no_headers") }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTHeader } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
const t = useI18n()
defineProps<{
headers: HoppRESTHeader[]
inheritedHeaders?: HoppInheritedProperty["headers"]
}>()
function hasItems<T>(value: T[] | undefined): boolean {
return !!value && value.length > 0
}
</script>

View file

@ -0,0 +1,66 @@
<template>
<div v-if="hasItems(params)" class="max-w-2xl space-y-2">
<h2
class="text-sm font-semibold text-secondaryDark flex items-end px-4 p-2 border-b border-divider"
>
<span>{{ t("documentation.parameters.title") }}</span>
</h2>
<div class="px-4 py-2 flex flex-col">
<div class="space-y-3">
<div
v-for="(param, index) in params"
:key="index"
class="flex items-center space-x-4"
>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
param.key
}}</span>
<span class="px-1 w-32">{{ param.value }}</span>
<span
v-if="param.description"
class="px-1 w-56 text-xs text-secondaryLight"
>
{{ param.description }}
</span>
</div>
<div class="flex items-center">
<span
class="px-2 py-0.5 text-xs rounded"
:class="
param.active
? 'bg-green-500/20 text-green-500'
: 'bg-red-500/20 text-red-500'
"
>
{{
param.active ? t("documentation.yes") : t("documentation.no")
}}
</span>
</div>
</div>
<div
v-if="params.length === 0"
class="text-secondaryLight text-sm py-1"
>
{{ t("documentation.parameters.no_params") }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTParam } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
defineProps<{
params: HoppRESTParam[]
}>()
function hasItems<T>(value: T[] | undefined): boolean {
return !!value && value.length > 0
}
</script>

View file

@ -0,0 +1,101 @@
<template>
<div v-if="body && body.contentType" class="max-w-2xl space-y-2">
<h2
class="text-sm font-semibold text-secondaryDark flex items-end px-4 p-2 border-b border-divider"
>
{{ t("documentation.body.title") }}
</h2>
<div class="p-4">
<div class="flex items-center mb-2">
<span class="font-medium text-secondaryDark w-32"
>{{ t("documentation.body.content_type") }}:</span
>
<span class="px-2 py-1 text-xs rounded bg-divider text-secondaryDark">
{{ body.contentType }}
</span>
</div>
<!-- Display body content based on type -->
<div v-if="body.contentType === 'application/json'">
<pre
class="bg-primaryLight p-3 rounded my-2 overflow-auto max-h-64 text-sm font-mono text-secondaryLight"
>{{ formatJSON(body.body) }}</pre
>
</div>
<div v-else-if="body.contentType === 'application/x-www-form-urlencoded'">
<table class="w-full border-collapse mt-2">
<thead class="">
<tr>
<th class="text-left py-2 font-semibold text-secondaryDark">
{{ t("documentation.key") }}
</th>
<th class="text-left py-2 font-semibold text-secondaryDark">
{{ t("documentation.value") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in parseFormData(body.body)"
:key="index"
class="border-t border-divider"
>
<td class="py-2">
{{ item.key }}
</td>
<td class="py-2 text-secondaryLight">
{{ item.value }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-else>
<pre
class="bg-primaryLight p-3 rounded my-2 overflow-auto max-h-64 text-sm font-mono text-secondaryLight"
>{{ body.body }}</pre
>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTReqBody, parseRawKeyValueEntries } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
defineProps<{
body: HoppRESTReqBody | null | undefined
}>()
/**
* Format JSON string for display
* @param jsonString String to format
* @returns Formatted JSON string
*/
function formatJSON(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString || "{}")
return JSON.stringify(parsed, null, 2)
} catch (e) {
return jsonString || ""
}
}
/**
* Parse form data into key-value pairs
* @param formData Form data string
* @returns Array of key-value pairs
*/
function parseFormData(formData: string): { key: string; value: string }[] {
try {
return typeof formData === "string" ? parseRawKeyValueEntries(formData) : []
} catch (e) {
return []
}
}
</script>

View file

@ -0,0 +1,247 @@
<template>
<div v-if="hasResponseExamples" class="max-w-2xl space-y-2">
<h2
class="text-sm font-semibold text-secondaryDark flex items-end px-4 p-2 border-b border-divider"
>
{{ t("documentation.response.title") }}
</h2>
<div
v-if="responseExamples && responseExamples.length > 0"
class="border border-divider"
>
<HoppSmartTabs
v-model="selectedResponseTab"
styles="sticky overflow-x-auto flex-shrink-0 z-10 bg-primary "
>
<HoppSmartTab
v-for="(example, index) in responseExamples"
:id="`response-${index}`"
:key="index"
:label="String(example.statusCode)"
class="flex h-full w-full flex-1 flex-col"
>
<div class="rounded-md overflow-hidden my-4">
<div class="px-4 py-2 border-b border-divider">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-secondary">
{{ example.name || "Untitled" }}
</span>
</div>
<HoppSmartItem
:icon="IconCopy"
:title="t('documentation.copy_response')"
@click="copyResponseExample(example)"
/>
</div>
</div>
<HoppSmartTabs
v-model="selectedContentTabs[index]"
styles="sticky overflow-x-auto flex-shrink-0 z-10 bg-primary"
>
<HoppSmartTab
v-if="example.body"
id="body"
:label="t('documentation.response_body')"
class="flex h-full w-full flex-1 flex-col"
>
<div class="p-4">
<div v-if="isJsonResponse(example)">
<pre
class="bg-primaryLight p-3 rounded my-2 overflow-auto max-h-64 text-sm font-mono text-secondaryLight"
>{{ formatJSON(example.body) }}</pre
>
</div>
<div v-else>
<pre
class="bg-primaryLight p-3 rounded my-2 overflow-auto max-h-64 text-sm font-mono text-secondaryLight"
>{{ example.body }}</pre
>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab
v-if="example.headers && example.headers.length > 0"
id="headers"
:label="`${t('documentation.response_headers')} (${example.headers.length})`"
class="flex h-full w-full flex-1 flex-col"
>
<div class="p-4">
<table class="w-full border-collapse text-sm">
<thead class="bg-divider/20">
<tr>
<th
class="text-left py-2 px-3 font-semibold text-secondaryDark text-xs"
>
{{ t("documentation.key") }}
</th>
<th
class="text-left py-2 px-3 font-semibold text-secondaryDark text-xs"
>
{{ t("documentation.value") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(header, headerIndex) in example.headers"
:key="headerIndex"
class="border-t border-divider"
>
<td class="py-2 px-3 text-accent text-xs">
{{ header.key }}
</td>
<td class="py-2 px-3 text-secondaryLight text-xs">
{{ header.value }}
</td>
</tr>
</tbody>
</table>
</div>
</HoppSmartTab>
<template #actions>
<div
v-if="example.statusCode"
class="flex items-center gap-2 px-4"
>
<span
class="px-1 py-.5 text-tiny rounded"
:class="getStatusCodeClass(example.statusCode)"
>
{{ example.statusCode }} -
{{ getStatusCodeReasonPhrase(example.statusCode) }}
</span>
</div>
</template>
</HoppSmartTabs>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
<div v-else class="text-center py-8 text-secondaryLight">
<icon-lucide-file-text class="mx-auto mb-2" size="24" />
<p class="text-sm">{{ t("documentation.response.no_examples") }}</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue"
import IconCopy from "~icons/lucide/copy"
import { useToast } from "~/composables/toast"
import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
interface ResponseExample {
name?: string
statusCode?: number
headers?: Array<{ key: string; value: string }>
body?: string
contentType?: string
}
const props = defineProps<{
responseExamples?: ResponseExample[] | null
}>()
const toast = useToast()
const selectedResponseTab = ref<string>("response-0")
const selectedContentTabs = ref<Record<number, string>>({})
// Initialize tabs when responseExamples change
watch(
() => props.responseExamples,
(newExamples) => {
if (newExamples && newExamples.length > 0) {
// Set default response tab to the first example
selectedResponseTab.value = "response-0"
// Initialize content tabs for each response example
const newSelectedTabs: Record<number, string> = {}
newExamples.forEach((example, index) => {
// Default to "body" tab if body exists, otherwise "headers"
newSelectedTabs[index] = example.body ? "body" : "headers"
})
selectedContentTabs.value = newSelectedTabs
}
},
{ immediate: true }
)
const hasResponseExamples = computed(() => {
return props.responseExamples && props.responseExamples.length > 0
})
/**
* Returns the appropriate CSS class for styling the status code badge
* @param statusCode The HTTP status code
* @returns CSS class string for the status code badge
*/
function getStatusCodeClass(statusCode: number): string {
if (statusCode >= 200 && statusCode < 300) {
return "bg-green-500/10 text-green-500 "
} else if (statusCode >= 300 && statusCode < 400) {
return "bg-yellow-500/10 text-yellow-500"
} else if (statusCode >= 400 && statusCode < 500) {
return "bg-orange-500/10 text-orange-500"
} else if (statusCode >= 500) {
return "bg-red-500/10 text-red-500"
}
return "bg-secondaryLight/20 text-secondaryLight"
}
/**
* Check if the response is JSON based on content type or body structure
* @param example Response example
* @returns Boolean indicating if response is JSON
*/
function isJsonResponse(example: ResponseExample): boolean {
if (example.contentType?.includes("application/json")) {
return true
}
// Try to parse as JSON to determine if it's valid JSON
try {
JSON.parse(example.body || "")
return true
} catch (e) {
return false
}
}
/**
* Format JSON string for display
* @param jsonString String to format
* @returns Formatted JSON string
*/
function formatJSON(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString || "{}")
return JSON.stringify(parsed, null, 2)
} catch (e) {
return jsonString || ""
}
}
/**
* Copy response example to clipboard
* @param example Response example to copy
*/
async function copyResponseExample(example: ResponseExample): Promise<void> {
try {
const responseText = example.body || ""
await navigator.clipboard.writeText(responseText)
toast.success(t("documentation.response.example_copied"))
} catch (err) {
console.error("Failed to copy response example: ", err)
toast.error(t("documentation.response.example_copy_failed"))
}
}
</script>

View file

@ -0,0 +1,83 @@
<template>
<div v-if="hasItems(variables)" class="max-w-2xl space-y-2">
<h2
class="text-sm font-semibold text-secondaryDark flex items-end px-4 p-2 border-b border-divider"
>
<span>{{ title }}</span>
</h2>
<div class="px-4 py-2 flex flex-col">
<div class="space-y-3">
<div
v-for="(variable, index) in variables"
:key="index"
class="flex items-center space-x-4"
>
<div class="flex items-center">
<span class="font-medium text-secondaryDark w-32">{{
variable.key
}}</span>
<span class="px-1">{{ getVariableValue(variable) }}</span>
</div>
<div v-if="'active' in variable" class="flex items-center">
<span
class="px-2 py-0.5 text-xs rounded"
:class="
variable.active
? 'bg-green-500/20 text-green-500'
: 'bg-red-500/20 text-red-500'
"
>
{{
variable.active ? t("documentation.yes") : t("documentation.no")
}}
</span>
</div>
</div>
<div
v-if="variables.length === 0"
class="text-secondaryLight text-sm py-1"
>
{{ t("documentation.variables.no_vars") }}
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {
HoppRESTRequestVariable,
HoppCollectionVariable,
} from "@hoppscotch/data"
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const props = defineProps<{
variables: (HoppRESTRequestVariable | HoppCollectionVariable)[]
title?: string
}>()
const title = computed(() => props.title || t("documentation.variables.title"))
function hasItems<T>(value: T[] | undefined): boolean {
return !!value && value.length > 0
}
function getVariableValue(
variable: HoppRESTRequestVariable | HoppCollectionVariable
): string {
if ("value" in variable) {
// Request variable
return variable.value
} else if ("secret" in variable && variable.secret) {
// Collection variable (secret)
return "••••••••"
} else if ("initialValue" in variable) {
// Collection variable (not secret)
return variable.initialValue
}
return ""
}
</script>

View file

@ -53,6 +53,8 @@
displayModalImportExport(true, 'my-collections') displayModalImportExport(true, 'my-collections')
" "
@duplicate-collection="duplicateCollection" @duplicate-collection="duplicateCollection"
@open-documentation="openDocumentation"
@open-request-documentation="openRequestDocumentation"
@duplicate-request="duplicateRequest" @duplicate-request="duplicateRequest"
@duplicate-response="duplicateResponse" @duplicate-response="duplicateResponse"
@edit-properties="editProperties" @edit-properties="editProperties"
@ -105,6 +107,8 @@
@edit-request="editRequest" @edit-request="editRequest"
@edit-response="editResponse" @edit-response="editResponse"
@edit-properties="editProperties" @edit-properties="editProperties"
@open-documentation="openDocumentation"
@open-request-documentation="openRequestDocumentation"
@create-mock-server="createTeamMockServer" @create-mock-server="createTeamMockServer"
@export-data="exportData" @export-data="exportData"
@expand-team-collection="expandTeamCollection" @expand-team-collection="expandTeamCollection"
@ -209,12 +213,33 @@
collectionsType.type === 'team-collections' && hasTeamWriteAccess collectionsType.type === 'team-collections' && hasTeamWriteAccess
" "
:has-team-write-access=" :has-team-write-access="
collectionsType.type === 'team-collections' ? hasTeamWriteAccess : true hasTeamWriteAccess || collectionsType.type === 'my-collections'
" "
source="REST" source="REST"
@hide-modal="displayModalEditProperties(false)" @hide-modal="displayModalEditProperties(false)"
@set-collection-properties="setCollectionProperties" @set-collection-properties="setCollectionProperties"
/> />
<CollectionsDocumentation
v-if="showModalDocumentation"
:show="showModalDocumentation"
:path-or-i-d="editingCollectionPath"
:collection="editingCollection"
:collection-i-d="editingCollectionID ?? undefined"
:folder-path="editingFolderPath"
:request-index="editingRequestIndex"
:request-i-d="editingRequestID"
:request="editingRequest"
:is-team-collection="editingCollectionIsTeam"
:team-i-d="
collectionsType.type === 'team-collections'
? collectionsType.selectedTeam?.teamID
: undefined
"
:has-team-write-access="
hasTeamWriteAccess || collectionsType.type === 'my-collections'
"
@hide-modal="displayModalDocumentation(false)"
/>
<!-- `selectedCollectionID` is guaranteed to be a string when `showCollectionsRunnerModal` is `true` --> <!-- `selectedCollectionID` is guaranteed to be a string when `showCollectionsRunnerModal` is `true` -->
<HttpTestRunnerModal <HttpTestRunnerModal
@ -254,6 +279,7 @@ import { useReadonlyStream } from "~/composables/stream"
import { defineActionHandler, invokeAction } from "~/helpers/actions" import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
import { import {
CollectionDataProps,
getCompleteCollectionTree, getCompleteCollectionTree,
teamCollToHoppRESTColl, teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers" } from "~/helpers/backend/helpers"
@ -369,18 +395,23 @@ const collectionsType = ref<CollectionType>({
// Collection Data // Collection Data
const editingCollection = ref<HoppCollection | TeamCollection | null>(null) const editingCollection = ref<HoppCollection | TeamCollection | null>(null)
const editingCollectionIsTeam = ref<boolean>(false)
const editingCollectionName = ref<string | null>(null) const editingCollectionName = ref<string | null>(null)
const editingCollectionIndex = ref<number | null>(null) const editingCollectionIndex = ref<number | null>(null)
const editingCollectionID = ref<string | null>(null) const editingCollectionID = ref<string | null>(null)
const editingCollectionPath = ref<string | null>(null)
const editingFolder = ref<HoppCollection | TeamCollection | null>(null) const editingFolder = ref<HoppCollection | TeamCollection | null>(null)
const editingFolderName = ref<string | null>(null) const editingFolderName = ref<string | null>(null)
const editingFolderPath = ref<string | null>(null) const editingFolderPath = ref<string | null>(null)
const editingRequest = ref<HoppRESTRequest | null>(null) const editingRequest = ref<HoppRESTRequest | null>(null)
const editingRequestName = ref("") const editingRequestName = ref("")
const editingResponseName = ref("") const editingResponseName = ref("")
const editingResponseOldName = ref("") const editingResponseOldName = ref("")
const editingRequestIndex = ref<number | null>(null) const editingRequestIndex = ref<number | null>(null)
const editingRequestID = ref<string | null>(null) const editingRequestID = ref<string | null>(null)
const editingResponseID = ref<string | null>(null) const editingResponseID = ref<string | null>(null)
const editingProperties = ref<EditingProperties>({ const editingProperties = ref<EditingProperties>({
@ -720,6 +751,7 @@ const showModalEditRequest = ref(false)
const showModalEditResponse = ref(false) const showModalEditResponse = ref(false)
const showModalImportExport = ref(false) const showModalImportExport = ref(false)
const showModalEditProperties = ref(false) const showModalEditProperties = ref(false)
const showModalDocumentation = ref(false)
const showConfirmModal = ref(false) const showConfirmModal = ref(false)
const showTeamModalAdd = ref(false) const showTeamModalAdd = ref(false)
@ -799,6 +831,12 @@ const displayTeamModalAdd = (show: boolean) => {
teamListAdapter.fetchList() teamListAdapter.fetchList()
} }
const displayModalDocumentation = (show: boolean) => {
showModalDocumentation.value = show
if (!show) resetSelectedData()
}
const addNewRootCollection = async (name: string) => { const addNewRootCollection = async (name: string) => {
if (collectionsType.value.type === "my-collections") { if (collectionsType.value.type === "my-collections") {
modalLoadingState.value = true modalLoadingState.value = true
@ -818,6 +856,7 @@ const addNewRootCollection = async (name: string) => {
authActive: true, authActive: true,
}, },
variables: [], variables: [],
description: "",
}) })
) )
@ -2917,6 +2956,7 @@ const editProperties = async (payload: {
collection: HoppCollection | TeamCollection collection: HoppCollection | TeamCollection
}) => { }) => {
const { collection, collectionIndex } = payload const { collection, collectionIndex } = payload
console.log("collection", collection)
const collectionId = collection.id ?? collectionIndex.split("/").pop() const collectionId = collection.id ?? collectionIndex.split("/").pop()
@ -2986,6 +3026,7 @@ const editProperties = async (payload: {
} as HoppRESTAuth, } as HoppRESTAuth,
headers: [] as HoppRESTHeaders, headers: [] as HoppRESTHeaders,
variables: [] as HoppCollectionVariable[], variables: [] as HoppCollectionVariable[],
description: null as string | null,
folders: null, folders: null,
requests: null, requests: null,
} }
@ -3013,11 +3054,16 @@ const editProperties = async (payload: {
}) })
) )
const collectionData: CollectionDataProps = {
auth: data.auth,
headers: data.headers,
variables: collectionVariables,
description: data.description,
}
coll = { coll = {
...coll, ...coll,
auth: data.auth, ...collectionData,
headers: data.headers as HoppRESTHeaders,
variables: collectionVariables as HoppCollectionVariable[],
} }
} }
@ -3117,9 +3163,13 @@ const setCollectionProperties = (newCollection: {
toast.success(t("collection.properties_updated")) toast.success(t("collection.properties_updated"))
} else if (hasTeamWriteAccess.value && collectionId) { } else if (hasTeamWriteAccess.value && collectionId) {
const data = { const data = {
auth: collection.auth, auth: collection.auth ?? {
headers: collection.headers, authType: "inherit",
variables: collection.variables, authActive: true,
},
headers: collection.headers ?? [],
variables: collection.variables ?? [],
description: collection.description ?? null,
} }
// Mark as loading BEFORE triggering async update to avoid race conditions and push the collectionId to the loading array // Mark as loading BEFORE triggering async update to avoid race conditions and push the collectionId to the loading array
@ -3130,7 +3180,7 @@ const setCollectionProperties = (newCollection: {
} }
pipe( pipe(
updateTeamCollection(collectionId, JSON.stringify(data), undefined), updateTeamCollection(collectionId, data, undefined),
TE.match( TE.match(
(err: GQLError<string>) => { (err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`) toast.error(`${getErrorMessage(err)}`)
@ -3218,6 +3268,62 @@ const sortCollections = (payload: {
}) })
} }
const openDocumentation = ({
pathOrID,
collectionRefID,
collection,
}: {
pathOrID: string
collectionRefID: string
collection: HoppCollection | TeamCollection
}) => {
console.log("Open documentation for", pathOrID, collectionRefID, collection)
editingCollectionPath.value = pathOrID
editingCollection.value = collection
editingCollectionIsTeam.value =
collectionsType.value.type === "team-collections"
editingCollectionID.value =
collectionsType.value.type === "team-collections"
? (collection.id ?? null)
: ((collection as HoppCollection).id ??
(collection as HoppCollection)._ref_id ??
null)
displayModalDocumentation(true)
}
const openRequestDocumentation = ({
folderPath,
requestIndex,
requestRefID,
request,
}: {
folderPath: string
requestIndex: string
requestRefID?: string
request: HoppRESTRequest
}) => {
console.log(
"Open documentation for request",
folderPath,
requestIndex,
requestRefID,
request
)
// editingCollectionPath.value = pathOrID
// editingCollection.value = collection
editingRequest.value = request
editingFolderPath.value = folderPath
editingRequestIndex.value = parseInt(requestIndex)
editingRequestID.value = requestIndex
editingCollectionID.value = folderPath.split("/").at(-1) ?? null
editingCollectionIsTeam.value =
collectionsType.value.type === "team-collections"
displayModalDocumentation(true)
}
const resolveConfirmModal = (title: string | null) => { const resolveConfirmModal = (title: string | null) => {
if (title === `${t("confirm.remove_collection")}`) onRemoveCollection() if (title === `${t("confirm.remove_collection")}`) onRemoveCollection()
else if (title === `${t("confirm.remove_request")}`) onRemoveRequest() else if (title === `${t("confirm.remove_request")}`) onRemoveRequest()

View file

@ -0,0 +1,192 @@
<template>
<main class="flex-1 flex overflow-hidden">
<div class="w-80 border-r border-divider bg-primary overflow-y-auto h-full">
<CollectionsDocumentationCollectionStructure
v-if="collectionData"
:collection="collectionData"
:is-doc-modal="false"
@request-select="handleRequestSelect"
@folder-select="handleFolderSelect"
@scroll-to-top="handleScrollToTop"
/>
</div>
<div ref="mainContentRef" class="flex-1 p-6 overflow-y-auto h-full">
<div class="flex-1 min-w-0 flex flex-col space-y-8">
<div class="mb-8 overflow-hidden">
<CollectionsDocumentationCollectionPreview
v-if="collectionData"
:collection="collectionData"
:documentation-description="collectionData.description || ''"
:path-or-i-d="null"
:read-only="true"
/>
</div>
<div
v-if="allItems.length > 0"
class="space-y-8 mt-8 divide-y divide-divider"
>
<div
v-for="item in allItems"
:id="`doc-item-${item.id}`"
:key="item.id"
class="flex flex-col py-4 scroll-mt-14"
>
<CollectionsDocumentationCollectionPreview
v-if="item.type === 'folder'"
:collection="item.item as HoppCollection"
:documentation-description="
(item.item as HoppCollection).description || ''
"
:path-or-i-d="null"
:read-only="true"
:inherited-properties="getInheritedProperties(item)"
/>
<CollectionsDocumentationRequestPreview
v-if="item.type === 'request'"
:request="item.item as HoppRESTRequest"
:documentation-description="
(item.item as HoppRESTRequest).description || ''
"
:collection-i-d="collectionData.id"
:inherited-properties="getInheritedProperties(item)"
:read-only="true"
/>
</div>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { PropType, ref, onMounted } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { useRouter, useRoute } from "vue-router"
type DocumentationItem = {
id: string
type: "folder" | "request"
item: HoppCollection | HoppRESTRequest
inheritedProperties: HoppInheritedProperty
}
const props = defineProps({
collectionData: {
type: Object as PropType<HoppCollection>,
default: null,
},
allItems: {
type: Array as PropType<DocumentationItem[]>,
default: () => [],
},
updateUrlOnSelect: {
type: Boolean,
default: false,
},
})
const router = useRouter()
const route = useRoute()
/**
* Handles a request being selected from the collection structure sidebar
*/
const handleRequestSelect = (request: HoppRESTRequest) => {
const requestId = request.id || (request as any)._ref_id
if (requestId) {
scrollToItem(requestId)
if (props.updateUrlOnSelect) {
router.replace({ query: { ...route.query, section: requestId } })
}
} else {
scrollToItemByName(request.name, "request")
}
}
/**
* Handles a folder being selected from the collection structure sidebar
*/
const handleFolderSelect = (folder: HoppCollection) => {
const folderId = folder.id || (folder as any)._ref_id
if (folderId) {
scrollToItem(folderId)
if (props.updateUrlOnSelect) {
router.replace({ query: { ...route.query, section: folderId } })
}
} else {
scrollToItemByName(folder.name, "folder")
}
}
/**
* Scrolls to a specific item by its ID
*/
const scrollToItem = (id: string): void => {
setTimeout(() => {
const element = document.getElementById(`doc-item-${id}`)
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
})
} else {
console.log("Item not found:", id)
}
}, 100)
}
/**
* Backup function that scrolls by name and type if ID is not available
*/
const scrollToItemByName = (name: string, type: "request" | "folder") => {
const item = props.allItems.find(
(item) => item.item.name === name && item.type === type
)
if (item) {
scrollToItem(item.id)
} else {
console.log(`${type} with name "${name}" not found in allItems`)
}
}
const mainContentRef = ref<HTMLElement | null>(null)
/**
* Scrolls the main content to the top
*/
const handleScrollToTop = () => {
if (mainContentRef.value) {
mainContentRef.value.scrollTo({
top: 0,
behavior: "smooth",
})
}
// Clear the section query parameter when scrolling to top
if (props.updateUrlOnSelect) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { section, ...restQuery } = route.query
router.replace({ query: restQuery })
}
}
const getInheritedProperties = (
item: DocumentationItem
): HoppInheritedProperty => {
return item.inheritedProperties
}
// Scroll to the section specified in the URL on mount
onMounted(() => {
if (props.updateUrlOnSelect) {
const sectionId = route.query.section as string
if (sectionId) {
scrollToItem(sectionId)
}
}
})
</script>

View file

@ -0,0 +1,43 @@
<template>
<header class="border-b border-divider bg-primary">
<div class="px-6 py-4">
<div class="flex items-center space-x-8">
<div>
<span
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
>
{{ instanceDisplayName }}
</span>
</div>
<div class="flex items-center gap-4">
<span class="text-md font-bold text-secondaryDark">
{{ publishedDoc?.title || "Untitled Project" }}
</span>
<!-- TODO: Add version (will be added in next iteration) -->
<!-- <span
v-if="publishedDoc?.version"
class="px-2 py-0.5 text-xs font-medium rounded-md bg-accent/10 text-accent"
>
{{ publishedDoc.version }}
</span> -->
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { PropType } from "vue"
import { PublishedDocs } from "~/helpers/backend/graphql"
defineProps({
publishedDoc: {
type: Object as PropType<Partial<PublishedDocs> | null>,
default: null,
},
instanceDisplayName: {
type: String,
default: "Hoppscotch",
},
})
</script>

View file

@ -0,0 +1,94 @@
<template>
<div class="flex flex-col flex-1">
<div class="border-b border-divider bg-primary px-6 py-4">
<div class="flex items-center space-x-8">
<div class="w-24 h-6 bg-primaryLight rounded animate-pulse"></div>
<div class="flex items-center gap-4">
<div class="w-48 h-6 bg-primaryLight rounded animate-pulse"></div>
<div class="w-12 h-6 bg-primaryLight rounded animate-pulse"></div>
</div>
</div>
</div>
<div class="flex flex-1">
<div
class="w-80 border-r border-divider bg-primary p-4 space-y-6 hidden md:block"
>
<div class="h-8 bg-primaryLight rounded animate-pulse"></div>
<div class="space-y-4">
<div v-for="i in 8" :key="i" class="flex items-center space-x-3">
<div class="w-4 h-4 bg-primaryLight rounded animate-pulse"></div>
<div
class="h-4 bg-primaryLight rounded animate-pulse"
:class="i % 2 === 0 ? 'w-2/3' : 'w-3/4'"
></div>
</div>
</div>
</div>
<div class="flex-1 p-6 space-y-8 overflow-hidden relative">
<div
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<div
class="bg-primary px-4 py-2 shadow-sm backdrop-blur-sm flex items-center gap-3"
>
<div
class="animate-spin rounded-full h-4 w-4 border-b-2 border-secondaryLight"
></div>
<span class="text-secondaryLight text-sm font-medium animate-pulse">
Loading documentation...
</span>
</div>
</div>
<div class="space-y-4 opacity-50">
<div
class="h-10 bg-primaryLight rounded w-1/3 animate-pulse"
style="animation-delay: 0.1s"
></div>
<div class="space-y-2">
<div
class="h-4 bg-primaryLight rounded w-2/3 animate-pulse"
style="animation-delay: 0.2s"
></div>
<div
class="h-4 bg-primaryLight rounded w-1/2 animate-pulse"
style="animation-delay: 0.3s"
></div>
</div>
</div>
<div class="space-y-6 opacity-50">
<div
v-for="i in 3"
:key="i"
class="border border-divider rounded-lg p-6 space-y-4"
:style="{ animationDelay: `${0.3 + i * 0.1}s` }"
>
<div class="flex items-center space-x-4">
<div
class="w-12 h-6 bg-primaryLight rounded animate-pulse"
:style="{ animationDelay: `${0.4 + i * 0.1}s` }"
></div>
<div
class="h-6 bg-primaryLight rounded w-1/4 animate-pulse"
:style="{ animationDelay: `${0.5 + i * 0.1}s` }"
></div>
</div>
<div class="space-y-2">
<div
class="h-4 bg-primaryLight rounded w-full animate-pulse"
:style="{ animationDelay: `${0.6 + i * 0.1}s` }"
></div>
<div
class="h-4 bg-primaryLight rounded w-5/6 animate-pulse"
:style="{ animationDelay: `${0.7 + i * 0.1}s` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,23 @@
import { computed } from "vue"
import { useSetting } from "~/composables/settings"
/**
* Composable to determine documentation visibility based on experimental flags
*/
export function useDocumentationVisibility() {
const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting(
"ENABLE_EXPERIMENTAL_DOCUMENTATION"
)
/**
* Check if documentation should be visible based on experimental flag
*/
const isDocumentationVisible = computed(
() => ENABLE_EXPERIMENTAL_DOCUMENTATION.value
)
return {
isDocumentationVisible,
}
}

View file

@ -0,0 +1,162 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { ref, readonly } from "vue"
export interface DocumentationItem {
type: "folder" | "request"
item: HoppCollection | HoppRESTRequest
parentPath: string
id: string
pathOrID?: string | null
folderPath?: string | null
requestIndex?: number | null
requestID?: string | null
}
interface QueueItem {
collection: HoppCollection
pathOrID: string | null
isTeamCollection: boolean
resolve: (items: DocumentationItem[]) => void
reject: (error: Error) => void
}
const worker = new Worker(
new URL("../helpers/workers/documentation.worker.ts", import.meta.url),
{
type: "module",
}
)
// Global queue state
const queue: QueueItem[] = []
let isWorkerBusy = false
// Global state refs (shared across composables)
const isProcessing = ref<boolean>(false)
const progress = ref<number>(0)
const processedCount = ref<number>(0)
const totalCount = ref<number>(0)
// Worker message handler
worker.onmessage = (event) => {
const { type } = event.data
switch (type) {
case "DOCUMENTATION_PROGRESS":
progress.value = event.data.progress
processedCount.value = event.data.processed
totalCount.value = event.data.total
break
case "DOCUMENTATION_RESULT":
if (queue.length > 0) {
const currentItem = queue[0] // The item currently being processed
// Parse the stringified items
const items = JSON.parse(event.data.items) as DocumentationItem[]
currentItem.resolve(items)
// Remove completed item and process next
queue.shift()
processQueue()
}
break
case "DOCUMENTATION_ERROR":
if (queue.length > 0) {
const currentItem = queue[0]
currentItem.reject(new Error(event.data.error))
// Remove failed item and process next
queue.shift()
processQueue()
}
break
}
}
worker.onerror = (error) => {
if (queue.length > 0) {
const currentItem = queue[0]
currentItem.reject(new Error(`Worker error: ${error.message}`))
// Remove failed item and process next
queue.shift()
processQueue()
}
}
function processQueue() {
if (queue.length === 0) {
isWorkerBusy = false
isProcessing.value = false
progress.value = 100 // Ensure progress shows complete
return
}
isWorkerBusy = true
isProcessing.value = true
progress.value = 0
processedCount.value = 0
totalCount.value = 0
const nextItem = queue[0]
try {
const collectionString = JSON.stringify(nextItem.collection)
worker.postMessage({
type: "GATHER_DOCUMENTATION",
collection: collectionString,
pathOrID: nextItem.pathOrID,
isTeamCollection: nextItem.isTeamCollection,
})
} catch (error) {
nextItem.reject(
new Error(
`Failed to serialize collection: ${error instanceof Error ? error.message : String(error)}`
)
)
queue.shift()
processQueue()
}
}
export function useDocumentationWorker() {
/**
* Process documentation using the worker
*/
function processDocumentation(
collection: HoppCollection,
pathOrID: string | null,
isTeamCollection: boolean = false
): Promise<DocumentationItem[]> {
return new Promise((resolve, reject) => {
if (!collection) {
resolve([])
return
}
// Add to queue
queue.push({
collection,
pathOrID,
isTeamCollection,
resolve,
reject,
})
// If worker is not busy, start processing
if (!isWorkerBusy) {
processQueue()
}
})
}
return {
isProcessing: readonly(isProcessing),
progress: readonly(progress),
processedCount: readonly(processedCount),
totalCount: readonly(totalCount),
processDocumentation,
}
}

View file

@ -0,0 +1,13 @@
mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) {
createPublishedDoc(args: $args) {
id
title
version
autoSync
url
createdOn
updatedOn
workspaceType
workspaceID
}
}

View file

@ -0,0 +1,3 @@
mutation DeletePublishedDoc($id: ID!) {
deletePublishedDoc(id: $id)
}

View file

@ -0,0 +1,11 @@
mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) {
updatePublishedDoc(id: $id, args: $args) {
id
title
version
autoSync
url
createdOn
updatedOn
}
}

View file

@ -0,0 +1,3 @@
query ExportCollectionToJSON($teamID: ID!, $collectionID: ID!) {
exportCollectionToJSON(teamID: $teamID, collectionID: $collectionID)
}

View file

@ -0,0 +1,22 @@
query PublishedDoc($id: ID!) {
publishedDoc(id: $id) {
id
title
version
autoSync
url
metadata
createdOn
updatedOn
creator {
uid
displayName
email
photoURL
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,24 @@
query TeamPublishedDocsList(
$teamID: ID!
$collectionID: ID!
$skip: Int!
$take: Int!
) {
teamPublishedDocsList(
teamID: $teamID
collectionID: $collectionID
skip: $skip
take: $take
) {
id
title
version
autoSync
url
collection {
id
}
createdOn
updatedOn
}
}

View file

@ -0,0 +1,14 @@
query UserPublishedDocsList($skip: Int!, $take: Int!) {
userPublishedDocsList(skip: $skip, take: $take) {
id
title
version
autoSync
url
collection {
id
}
createdOn
updatedOn
}
}

View file

@ -20,22 +20,25 @@ import { TeamRequest } from "../teams/TeamRequest"
import { GQLError, runGQLQuery } from "./GQLClient" import { GQLError, runGQLQuery } from "./GQLClient"
import { import {
ExportAsJsonDocument, ExportAsJsonDocument,
ExportCollectionToJsonDocument,
GetCollectionChildrenIDsDocument, GetCollectionChildrenIDsDocument,
GetCollectionRequestsDocument, GetCollectionRequestsDocument,
GetCollectionTitleAndDataDocument, GetCollectionTitleAndDataDocument,
} from "./graphql" } from "./graphql"
type TeamCollectionJSON = { type TeamCollectionJSON = {
id: string
name: string name: string
folders: TeamCollectionJSON[] folders: TeamCollectionJSON[]
requests: HoppRESTRequest[] requests: HoppRESTRequest[]
data: string data: string | null
} }
type CollectionDataProps = { export type CollectionDataProps = {
auth: HoppRESTAuth auth: HoppRESTAuth
headers: HoppRESTHeaders headers: HoppRESTHeaders
variables: HoppCollectionVariable[] variables: HoppCollectionVariable[]
description: string | null
} }
export const BACKEND_PAGE_SIZE = 10 export const BACKEND_PAGE_SIZE = 10
@ -116,6 +119,7 @@ const parseCollectionData = (
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
if (!data) { if (!data) {
@ -149,26 +153,36 @@ const parseCollectionData = (
defaultDataProps.variables defaultDataProps.variables
) )
const description =
typeof parsedData?.description === "string"
? parsedData.description
: defaultDataProps.description
return { return {
auth, auth,
headers, headers,
variables, variables,
description,
} }
} }
// Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection` // Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection`
const teamCollectionJSONToHoppRESTColl = ( export const teamCollectionJSONToHoppRESTColl = (
coll: TeamCollectionJSON coll: TeamCollectionJSON
): HoppCollection => { ): HoppCollection => {
const { auth, headers, variables } = parseCollectionData(coll.data) const { auth, headers, variables, description } = parseCollectionData(
coll.data
)
return makeCollection({ return makeCollection({
id: coll.id,
name: coll.name, name: coll.name,
folders: coll.folders.map(teamCollectionJSONToHoppRESTColl), folders: coll.folders?.map(teamCollectionJSONToHoppRESTColl),
requests: coll.requests, requests: coll.requests,
auth, auth,
headers, headers,
variables, variables,
description,
}) })
} }
@ -229,9 +243,10 @@ export const teamCollToHoppRESTColl = (
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
const { auth, headers, variables } = parseCollectionData(data) const { auth, headers, variables, description } = parseCollectionData(data)
return makeCollection({ return makeCollection({
id: coll.id, id: coll.id,
@ -241,6 +256,7 @@ export const teamCollToHoppRESTColl = (
auth: auth ?? { authType: "inherit", authActive: true }, auth: auth ?? { authType: "inherit", authActive: true },
headers: headers ?? [], headers: headers ?? [],
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
}) })
} }
@ -272,3 +288,36 @@ export const getTeamCollectionJSON = async (teamID: string) => {
const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl) const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl)
return E.right(JSON.stringify(hoppCollections, null, 2)) return E.right(JSON.stringify(hoppCollections, null, 2))
} }
/**
* Get the JSON string of a single collection of the specified team
* @param teamID - ID of the team
* @param collectionID - ID of the collection
*/
export const getSingleTeamCollectionJSON = async (
teamID: string,
collectionID: string
) => {
const data = await runGQLQuery({
query: ExportCollectionToJsonDocument,
variables: {
teamID,
collectionID,
},
})
if (E.isLeft(data)) {
return E.left(data.left.error.toString())
}
const collection = JSON.parse(data.right.exportCollectionToJSON)
if (!collection) {
const t = getI18n()
return E.left(t("error.no_collections_to_export"))
}
const hoppCollection = teamCollectionJSONToHoppRESTColl(collection)
return E.right(JSON.stringify(hoppCollection, null, 2))
}

View file

@ -0,0 +1,55 @@
import { runMutation } from "../GQLClient"
import {
CreatePublishedDocDocument,
CreatePublishedDocMutation,
CreatePublishedDocMutationVariables,
UpdatePublishedDocDocument,
UpdatePublishedDocMutation,
UpdatePublishedDocMutationVariables,
DeletePublishedDocDocument,
DeletePublishedDocMutation,
DeletePublishedDocMutationVariables,
CreatePublishedDocsArgs,
UpdatePublishedDocsArgs,
} from "../graphql"
type CreatePublishedDocError =
| "published_docs/creation_failed"
| "published_docs/invalid_collection"
| "team/invalid_id"
type UpdatePublishedDocError =
| "published_docs/update_failed"
| "published_docs/not_found"
type DeletePublishedDocError =
| "published_docs/deletion_failed"
| "published_docs/not_found"
export const createPublishedDoc = (doc: CreatePublishedDocsArgs) =>
runMutation<
CreatePublishedDocMutation,
CreatePublishedDocMutationVariables,
CreatePublishedDocError
>(CreatePublishedDocDocument, {
args: doc,
})
export const updatePublishedDoc = (id: string, doc: UpdatePublishedDocsArgs) =>
runMutation<
UpdatePublishedDocMutation,
UpdatePublishedDocMutationVariables,
UpdatePublishedDocError
>(UpdatePublishedDocDocument, {
id,
args: doc,
})
export const deletePublishedDoc = (id: string) =>
runMutation<
DeletePublishedDocMutation,
DeletePublishedDocMutationVariables,
DeletePublishedDocError
>(DeletePublishedDocDocument, {
id,
})

View file

@ -32,6 +32,7 @@ import {
UpdateTeamCollectionMutation, UpdateTeamCollectionMutation,
UpdateTeamCollectionMutationVariables, UpdateTeamCollectionMutationVariables,
} from "../graphql" } from "../graphql"
import { CollectionDataProps } from "../helpers"
type CreateNewRootCollectionError = "team_coll/short_title" type CreateNewRootCollectionError = "team_coll/short_title"
@ -135,7 +136,7 @@ export const importJSONToTeam = (collectionJSON: string, teamID: string) =>
export const updateTeamCollection = ( export const updateTeamCollection = (
collectionID: string, collectionID: string,
data?: string, data?: CollectionDataProps,
newTitle?: string newTitle?: string
) => ) =>
runMutation< runMutation<
@ -144,7 +145,7 @@ export const updateTeamCollection = (
"" ""
>(UpdateTeamCollectionDocument, { >(UpdateTeamCollectionDocument, {
collectionID, collectionID,
data, data: JSON.stringify(data),
newTitle, newTitle,
}) })

View file

@ -0,0 +1,255 @@
import * as TE from "fp-ts/TaskEither"
import * as E from "fp-ts/Either"
import { runGQLQuery } from "../GQLClient"
import {
UserPublishedDocsListDocument,
TeamPublishedDocsListDocument,
type UserPublishedDocsListQuery,
type TeamPublishedDocsListQuery,
PublishedDocDocument,
PublishedDocs,
} from "../graphql"
import {
HoppCollection,
makeCollection,
translateToNewRequest,
} from "@hoppscotch/data"
import type { CollectionDataProps } from "../helpers"
type GetUserPublishedDocsError = "user/not_authenticated"
type GetTeamPublishedDocsError = "team/not_found" | "team/access_denied"
// Type for a published doc item returned from list queries
export type PublishedDocListItem = {
id: string
title: string
version: string
autoSync: boolean
url: string
collection: {
id: string
}
createdOn: string
updatedOn: string
}
// Type for a full published doc returned from single doc query
export type PublishedDoc = PublishedDocListItem & {
metadata?: string
creator?: {
uid: string
displayName: string
email: string
photoURL: string
}
collection: {
id: string
title: string
}
}
// Type for the GraphQL query response
export type PublishedDocQuery = {
publishedDoc: PublishedDoc
}
type CollectionFolder = {
id?: string
folders: CollectionFolder[]
// Backend stores this as any, we translate it to HoppRESTRequest via translateToNewRequest
requests: any[]
name: string
data?: string
}
/**
* Parses the data field (stringified JSON) to extract auth, headers, variables, and description
* @param data The stringified JSON data from CollectionFolder
* @returns Parsed CollectionDataProps with defaults if parsing fails
*/
function parseCollectionDataFromString(data?: string): CollectionDataProps {
const defaultDataProps: CollectionDataProps = {
auth: { authType: "inherit", authActive: true },
headers: [],
variables: [],
description: null,
}
if (!data) {
return defaultDataProps
}
try {
const parsed = JSON.parse(data) as Partial<CollectionDataProps>
return {
auth: parsed.auth || defaultDataProps.auth,
headers: parsed.headers || defaultDataProps.headers,
variables: parsed.variables || defaultDataProps.variables,
description: parsed.description || defaultDataProps.description,
}
} catch (error) {
console.error("Failed to parse collection data:", error)
return defaultDataProps
}
}
/**
* Converts a CollectionFolder (from backend REST API) to HoppCollection format
* @param folder The CollectionFolder to convert
* @returns HoppCollection in the proper format
*/
export function collectionFolderToHoppCollection(
folder: CollectionFolder
): HoppCollection {
// Parse the data field to extract auth, headers, variables, and description
const { auth, headers, variables, description } =
parseCollectionDataFromString(folder.data)
return makeCollection({
name: folder.name,
folders: folder.folders.map(collectionFolderToHoppCollection),
requests: (folder.requests || []).map(translateToNewRequest),
auth,
headers,
variables,
description,
id: folder.id,
})
}
export const getUserPublishedDocs = (skip: number = 0, take: number = 100) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: UserPublishedDocsListDocument,
variables: { skip, take },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as UserPublishedDocsListQuery
return data.userPublishedDocsList
},
(error) => error as GetUserPublishedDocsError
)
export const getTeamPublishedDocs = (
teamID: string,
collectionID: string,
skip: number = 0,
take: number = 100
) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: TeamPublishedDocsListDocument,
variables: { teamID, collectionID, skip, take },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as TeamPublishedDocsListQuery
return data.teamPublishedDocsList
},
(error) => error as GetTeamPublishedDocsError
)
// Helper to find published doc for a specific collection
export const findPublishedDocForCollection = (
collectionID: string,
isTeam: boolean,
teamID?: string
): TE.TaskEither<
| GetUserPublishedDocsError
| GetTeamPublishedDocsError
| "published_docs/not_found",
PublishedDocListItem
> => {
const query: TE.TaskEither<
GetUserPublishedDocsError | GetTeamPublishedDocsError,
PublishedDocListItem[]
> = (
isTeam && teamID
? getTeamPublishedDocs(teamID, collectionID)
: getUserPublishedDocs()
) as TE.TaskEither<
GetUserPublishedDocsError | GetTeamPublishedDocsError,
PublishedDocListItem[]
>
return TE.chain(
(
docs: PublishedDocListItem[]
): TE.TaskEither<
| GetUserPublishedDocsError
| GetTeamPublishedDocsError
| "published_docs/not_found",
PublishedDocListItem
> => {
const publishedDoc = docs.find(
(doc) => doc.collection.id === collectionID
)
return publishedDoc
? TE.right(publishedDoc)
: TE.left("published_docs/not_found" as const)
}
)(query)
}
type GetPublishedDocError =
| "published_docs/not_found"
| "published_docs/unauthorized"
// Get a single published doc by ID (GraphQL)
export const getPublishedDocByID = (id: string) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: PublishedDocDocument,
variables: { id },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as PublishedDocQuery
return data.publishedDoc
},
(error) => {
console.error("Error fetching published doc:", error)
return "published_docs/not_found" as GetPublishedDocError
}
)
/**
*
* @param id - The ID of the published doc to fetch
* @param tree - The tree level to fetch (FULL or MINIMAL) Default is FULL so we can skip it, keeping it for future use
* @returns The published doc with the specified ID
*/
export const getPublishedDocByIDREST = (
id: string
//tree: "FULL" | "MINIMAL" = "FULL"
): TE.TaskEither<GetPublishedDocError, PublishedDocs> =>
TE.tryCatch(
async () => {
const backendUrl = import.meta.env.VITE_BACKEND_API_URL || ""
const response = await fetch(`${backendUrl}/published-docs/${id}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return await response.json()
},
(error) => {
console.error("Error fetching published doc via REST:", error)
return "published_docs/not_found" as GetPublishedDocError
}
)

View file

@ -550,12 +550,43 @@ const getHoppScripts = (
return { preRequestScript, testScript } return { preRequestScript, testScript }
} }
const getCollectionDescription = (
docField?: string | DescriptionDefinition
): string | null => {
if (!docField) {
return null
}
if (typeof docField === "string") {
return docField
} else if (typeof docField === "object" && "content" in docField) {
return docField.content || null
}
return null
}
const getRequestDescription = (
docField?: string | DescriptionDefinition
): string | null => {
if (!docField) {
return null
}
if (typeof docField === "string") {
return docField
} else if (typeof docField === "object" && "content" in docField) {
return docField.content || null
}
return null
}
const getHoppRequest = ( const getHoppRequest = (
item: Item, item: Item,
importScripts: boolean importScripts: boolean
): HoppRESTRequest => { ): HoppRESTRequest => {
const { preRequestScript, testScript } = getHoppScripts(item, importScripts) const { preRequestScript, testScript } = getHoppScripts(item, importScripts)
return makeRESTRequest({ return makeRESTRequest({
name: item.name, name: item.name,
endpoint: getHoppReqURL(item.request.url), endpoint: getHoppReqURL(item.request.url),
@ -571,6 +602,7 @@ const getHoppRequest = (
responses: getHoppResponses(item.responses), responses: getHoppResponses(item.responses),
preRequestScript, preRequestScript,
testScript, testScript,
description: getRequestDescription(item.request.description),
}) })
} }
@ -593,6 +625,7 @@ const getHoppFolder = (
auth: getHoppReqAuth(ig.auth), auth: getHoppReqAuth(ig.auth),
headers: [], headers: [],
variables: getHoppCollVariables(ig), variables: getHoppCollVariables(ig),
description: getCollectionDescription(ig.description),
}) })
export const getHoppCollections = ( export const getHoppCollections = (

View file

@ -0,0 +1,265 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { DocumentationItem } from "~/composables/useDocumentationWorker"
interface GatherDocumentationMessage {
type: "GATHER_DOCUMENTATION"
collection: string // JSON stringified collection
pathOrID: string | null
isTeamCollection?: boolean // Flag to indicate team collection
}
interface DocumentationProgressMessage {
type: "DOCUMENTATION_PROGRESS"
progress: number
processed: number
total: number
}
interface DocumentationResultMessage {
type: "DOCUMENTATION_RESULT"
items: string // JSON stringified items array
}
interface DocumentationErrorMessage {
type: "DOCUMENTATION_ERROR"
error: string
}
type IncomingDocumentationWorkerMessage = GatherDocumentationMessage
/**
* Gathers all items with documentation from the collection with async processing
*/
async function gatherAllItems(
collection: HoppCollection,
collectionPath: string | null,
isTeamCollection: boolean = false
): Promise<DocumentationItem[]> {
const items: DocumentationItem[] = []
let processedCount = 0
let totalCount = 0
let lastProgressUpdate = 0
if (!collection) {
return []
}
// First pass: count total items
const countItems = (coll: HoppCollection): number => {
let count = 0
if (coll.requests?.length) count += coll.requests.length
if (coll.folders?.length) {
count += coll.folders.length
coll.folders.forEach((folder) => {
count += countItems(folder)
})
}
return count
}
totalCount = countItems(collection)
// Send initial progress
self.postMessage({
type: "DOCUMENTATION_PROGRESS",
progress: 0,
processed: 0,
total: totalCount,
} satisfies DocumentationProgressMessage)
const baseCollectionPath = collectionPath || ""
const BATCH_SIZE = 20 // Process items in larger batches
const PROGRESS_UPDATE_THRESHOLD = 10 // Update progress every 10%
/**
* Update progress with throttling to avoid excessive messages
*/
const updateProgress = async (force = false) => {
const progress = Math.round((processedCount / totalCount) * 100)
if (force || progress - lastProgressUpdate >= PROGRESS_UPDATE_THRESHOLD) {
self.postMessage({
type: "DOCUMENTATION_PROGRESS",
progress,
processed: processedCount,
total: totalCount,
} satisfies DocumentationProgressMessage)
lastProgressUpdate = progress
// Yield control less frequently for better performance
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
/**
* Process folders recursively with optimized batching
*/
const processFoldersAsync = async (
folders: HoppCollection[],
parentPath: string = "",
currentFolderPath: string = ""
): Promise<void> => {
for (let folderIndex = 0; folderIndex < folders.length; folderIndex++) {
const folder = folders[folderIndex]
const folderId =
folder.id ||
("_ref_id" in folder ? folder._ref_id : undefined) ||
`folder-${folderIndex}`
let thisFolderPath: string
const pathSegment = isTeamCollection ? folderId : folderIndex.toString()
if (baseCollectionPath) {
thisFolderPath = currentFolderPath
? `${baseCollectionPath}/${currentFolderPath}/${pathSegment}`
: `${baseCollectionPath}/${pathSegment}`
} else {
thisFolderPath = currentFolderPath
? `${currentFolderPath}/${pathSegment}`
: `${pathSegment}`
}
// Add folder
items.push({
type: "folder",
item: folder,
parentPath,
id: folderId,
pathOrID: thisFolderPath,
requestIndex: null,
requestID: null,
})
processedCount++
// Process folder requests in batches
if (folder.requests?.length) {
for (let i = 0; i < folder.requests.length; i += BATCH_SIZE) {
const batchEnd = Math.min(i + BATCH_SIZE, folder.requests.length)
for (let j = i; j < batchEnd; j++) {
const request = folder.requests[j]
const requestId =
request.id ||
("_ref_id" in request ? request._ref_id : undefined) ||
`${folderId}-request-${j}`
items.push({
type: "request",
item: request as HoppRESTRequest,
parentPath: parentPath
? `${parentPath} / ${folder.name}`
: folder.name,
id: requestId,
folderPath: thisFolderPath,
requestIndex: j,
requestID: request.id,
})
processedCount++
}
await updateProgress()
}
}
// Process nested folders
if (folder.folders?.length) {
const newParentPath: string = parentPath
? `${parentPath} / ${folder.name}`
: folder.name
const relativeFolderPath = currentFolderPath
? `${currentFolderPath}/${pathSegment}`
: `${pathSegment}`
await processFoldersAsync(
folder.folders,
newParentPath,
relativeFolderPath
)
}
// Update progress less frequently
if (folderIndex % 5 === 0) {
await updateProgress()
}
}
}
if (collection.folders?.length) {
await processFoldersAsync(collection.folders)
}
// Process collection requests in larger batches
if (collection.requests?.length) {
for (let i = 0; i < collection.requests.length; i += BATCH_SIZE) {
const batchEnd = Math.min(i + BATCH_SIZE, collection.requests.length)
for (let j = i; j < batchEnd; j++) {
const request = collection.requests[j]
const requestId =
request.id ||
("_ref_id" in request ? request._ref_id : undefined) ||
`request-${j}`
items.push({
type: "request",
item: request as HoppRESTRequest,
parentPath: collection?.name || "",
id: requestId,
folderPath: baseCollectionPath,
requestIndex: j,
requestID: request.id,
})
processedCount++
}
await updateProgress()
}
}
// Send final progress update
await updateProgress(true)
return items
}
self.addEventListener(
"message",
async (event: MessageEvent<IncomingDocumentationWorkerMessage>) => {
const {
type,
collection: collectionString,
pathOrID,
isTeamCollection,
} = event.data
if (type === "GATHER_DOCUMENTATION") {
try {
// Parse the stringified collection
const collection = JSON.parse(collectionString) as HoppCollection
const items = await gatherAllItems(
collection,
pathOrID,
isTeamCollection || false
)
const result: DocumentationResultMessage = {
type: "DOCUMENTATION_RESULT",
items: JSON.stringify(items), // Stringify the result for cloning
}
self.postMessage(result)
} catch (error) {
const err: DocumentationErrorMessage = {
type: "DOCUMENTATION_ERROR",
error: error instanceof Error ? error.message : String(error),
}
self.postMessage(err)
}
}
}
)

View file

@ -37,6 +37,7 @@ const defaultRESTCollectionState = {
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}), }),
], ],
} }
@ -53,6 +54,7 @@ const defaultGraphqlCollectionState = {
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}), }),
], ],
} }
@ -362,6 +364,7 @@ const restCollectionDispatchers = defineDispatchers({
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}) })
const newState = state const newState = state
@ -1022,6 +1025,7 @@ const gqlCollectionDispatchers = defineDispatchers({
}, },
headers: [], headers: [],
variables: [], variables: [],
description: null,
}) })
const newState = state const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x)) const indexPaths = path.split("/").map((x) => parseInt(x))

View file

@ -85,6 +85,7 @@ export type SettingsDef = {
EXPERIMENTAL_SCRIPTING_SANDBOX: boolean EXPERIMENTAL_SCRIPTING_SANDBOX: boolean
ENABLE_EXPERIMENTAL_MOCK_SERVERS: boolean ENABLE_EXPERIMENTAL_MOCK_SERVERS: boolean
ENABLE_EXPERIMENTAL_DOCUMENTATION: boolean
} }
let defaultProxyURL = DEFAULT_HOPP_PROXY_URL let defaultProxyURL = DEFAULT_HOPP_PROXY_URL
@ -148,6 +149,7 @@ export const getDefaultSettings = (): SettingsDef => {
EXPERIMENTAL_SCRIPTING_SANDBOX: true, EXPERIMENTAL_SCRIPTING_SANDBOX: true,
ENABLE_EXPERIMENTAL_MOCK_SERVERS: true, ENABLE_EXPERIMENTAL_MOCK_SERVERS: true,
ENABLE_EXPERIMENTAL_DOCUMENTATION: true,
} }
} }

View file

@ -156,21 +156,32 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center py-4">
<HoppSmartToggle <div class="flex flex-col space-y-4 py-4">
:on="EXPERIMENTAL_SCRIPTING_SANDBOX" <div class="flex items-center">
@change="toggleSetting('EXPERIMENTAL_SCRIPTING_SANDBOX')" <HoppSmartToggle
> :on="EXPERIMENTAL_SCRIPTING_SANDBOX"
{{ t("settings.experimental_scripting_sandbox") }} @change="toggleSetting('EXPERIMENTAL_SCRIPTING_SANDBOX')"
</HoppSmartToggle> >
</div> {{ t("settings.experimental_scripting_sandbox") }}
<div class="flex items-center"> </HoppSmartToggle>
<HoppSmartToggle </div>
:on="ENABLE_EXPERIMENTAL_MOCK_SERVERS" <div class="flex items-center">
@change="toggleSetting('ENABLE_EXPERIMENTAL_MOCK_SERVERS')" <HoppSmartToggle
> :on="ENABLE_EXPERIMENTAL_MOCK_SERVERS"
{{ t("settings.enable_experimental_mock_servers") }} @change="toggleSetting('ENABLE_EXPERIMENTAL_MOCK_SERVERS')"
</HoppSmartToggle> >
{{ t("settings.enable_experimental_mock_servers") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="ENABLE_EXPERIMENTAL_DOCUMENTATION"
@change="toggleSetting('ENABLE_EXPERIMENTAL_DOCUMENTATION')"
>
{{ t("settings.enable_experimental_documentation") }}
</HoppSmartToggle>
</div>
</div> </div>
</section> </section>
</div> </div>
@ -365,6 +376,9 @@ const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting( const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
"ENABLE_EXPERIMENTAL_MOCK_SERVERS" "ENABLE_EXPERIMENTAL_MOCK_SERVERS"
) )
const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting(
"ENABLE_EXPERIMENTAL_DOCUMENTATION"
)
const supportedNamingStyles = [ const supportedNamingStyles = [
{ {

View file

@ -0,0 +1,208 @@
<template>
<div class="flex flex-col h-screen overflow-hidden bg-primary">
<DocumentationHeader
v-if="!loading && !error && publishedDoc"
:published-doc="publishedDoc"
:instance-display-name="instanceDisplayName"
/>
<DocumentationSkeleton v-if="loading" />
<div
v-else-if="error"
class="flex items-center justify-center flex-1 py-20"
>
<div class="flex flex-col items-center space-y-4 max-w-md text-center">
<IconAlertCircle class="w-16 h-16 text-red-500" />
<h2 class="text-xl font-semibold text-secondaryDark">
{{ t("error.something_went_wrong") }}
</h2>
<p class="text-secondaryLight">{{ error }}</p>
</div>
</div>
<DocumentationContent
v-else-if="collectionData"
:collection-data="collectionData"
:all-items="allItems"
:update-url-on-select="true"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue"
import { useRoute } from "vue-router"
import { useI18n } from "~/composables/i18n"
import {
getPublishedDocByIDREST,
collectionFolderToHoppCollection,
} from "~/helpers/backend/queries/PublishedDocs"
import * as E from "fp-ts/Either"
import IconAlertCircle from "~icons/lucide/alert-circle"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PublishedDocs } from "~/helpers/backend/graphql"
import { getKernelMode } from "@hoppscotch/kernel"
import { useService } from "dioc/vue"
import { InstanceSwitcherService } from "~/services/instance-switcher.service"
import { useReadonlyStream } from "~/composables/stream"
const route = useRoute()
const t = useI18n()
const kernelMode = getKernelMode()
const instanceSwitcherService =
kernelMode === "desktop" ? useService(InstanceSwitcherService) : null
const currentState =
kernelMode === "desktop" && instanceSwitcherService
? useReadonlyStream(
instanceSwitcherService.getStateStream(),
instanceSwitcherService.getCurrentState().value
)
: ref({
status: "disconnected",
instance: { displayName: "Hoppscotch" },
})
const instanceDisplayName = computed(() => {
if (currentState.value.status !== "connected") {
return "Hoppscotch"
}
return currentState.value.instance.displayName
})
const publishedDoc = ref<Partial<PublishedDocs> | null>(null)
const collectionData = ref<HoppCollection | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
type DocumentationItem = {
id: string
type: "folder" | "request"
item: HoppCollection | HoppRESTRequest
inheritedProperties: HoppInheritedProperty
}
/**
* Recursively flattens a collection into an array of documentation items
*/
const flattenCollection = (
collection: HoppCollection,
items: DocumentationItem[] = [],
inheritedProperties: HoppInheritedProperty | undefined = undefined
): DocumentationItem[] => {
const currentInheritedProps: HoppInheritedProperty = {
auth:
collection.auth.authType === "inherit"
? (inheritedProperties?.auth ?? {
parentID: "",
parentName: "",
inheritedAuth: { authType: "none", authActive: true },
})
: {
parentID: collection.id || "",
parentName: collection.name,
inheritedAuth: collection.auth,
},
headers: [
...(inheritedProperties?.headers || []),
...(collection.headers || []).map((h) => ({
parentID: collection.id || "",
parentName: collection.name,
inheritedHeader: h,
})),
],
variables: [
...(inheritedProperties?.variables || []),
{
parentID: collection.id || "",
parentName: collection.name,
inheritedVariables: (collection.variables || []).map((v) => ({
...v,
secret: v.secret,
})),
},
],
}
if (collection.folders && collection.folders.length > 0) {
collection.folders.forEach((folder: HoppCollection) => {
items.push({
id: folder.id || (folder as any)._ref_id || `folder-${folder.name}`,
type: "folder",
item: folder,
inheritedProperties: currentInheritedProps,
})
flattenCollection(folder, items, currentInheritedProps)
})
}
// collectionFolderToHoppCollection ensures all requests are converted to the latest version
if (collection.requests && collection.requests.length > 0) {
;(collection.requests as HoppRESTRequest[]).forEach((request) => {
items.push({
id: request.id || (request as any)._ref_id || `request-${request.name}`,
type: "request",
item: request,
inheritedProperties: currentInheritedProps,
})
})
}
return items
}
const allItems = computed<DocumentationItem[]>(() => {
if (!collectionData.value) return []
return flattenCollection(collectionData.value)
})
onMounted(async () => {
const docId = route.params.id as string
// will use in next iteration
//const version = route.params.version as string
if (!docId) {
error.value = "No document ID provided"
loading.value = false
return
}
// Fetch published doc using REST API (public access, no authentication required)
const result = await getPublishedDocByIDREST(docId)()
if (E.isLeft(result)) {
console.error("Error fetching published doc:", result.left)
error.value = "Published documentation not found"
loading.value = false
return
}
publishedDoc.value = {
autoSync: false,
createdOn: result.right.createdOn,
id: result.right.id,
updatedOn: result.right.updatedOn,
version: result.right.version,
metadata: result.right.metadata,
title: result.right.title,
creator: result.right.creator,
}
const publishedData = JSON.parse(result.right.documentTree)
// Convert the REST API response (CollectionFolder) to HoppCollection format
const hoppCollection = collectionFolderToHoppCollection(publishedData)
collectionData.value = hoppCollection
loading.value = false
})
</script>
<route lang="yaml">
meta:
layout: empty
</route>

View file

@ -0,0 +1,454 @@
import { describe, it, expect, beforeEach } from "vitest"
import { TestContainer } from "dioc/testing"
import {
HoppCollection,
HoppRESTRequest,
makeCollection,
makeRESTRequest,
} from "@hoppscotch/data"
import {
DocumentationService,
CollectionDocumentationItem,
RequestDocumentationItem,
SetCollectionDocumentationOptions,
SetRequestDocumentationOptions,
} from "../documentation.service"
describe("DocumentationService", () => {
let container: TestContainer
let service: DocumentationService
// Test data
const mockCollection: HoppCollection = makeCollection({
name: "Test Collection",
folders: [],
requests: [],
auth: { authType: "none", authActive: true },
headers: [],
variables: [],
id: "collection-123",
description: null,
})
const mockRequest: HoppRESTRequest = makeRESTRequest({
name: "Test Request",
endpoint: "https://api.example.com/test",
method: "GET",
headers: [],
params: [],
auth: { authType: "inherit", authActive: true },
preRequestScript: "",
testScript: "",
body: { contentType: null, body: null },
requestVariables: [],
responses: {},
description: null,
})
const mockCollectionOptions: SetCollectionDocumentationOptions = {
isTeamItem: false,
pathOrID: "test-path",
collectionData: mockCollection,
}
const mockTeamCollectionOptions: SetCollectionDocumentationOptions = {
isTeamItem: true,
teamID: "team-456",
pathOrID: "team-collection-789",
collectionData: mockCollection,
}
const mockRequestOptions: SetRequestDocumentationOptions = {
isTeamItem: false,
parentCollectionID: "collection-123",
folderPath: "test-folder",
requestIndex: 0,
requestData: mockRequest,
}
const mockTeamRequestOptions: SetRequestDocumentationOptions = {
isTeamItem: true,
teamID: "team-456",
parentCollectionID: "collection-123",
folderPath: "team-folder",
requestID: "request-789",
requestData: mockRequest,
}
beforeEach(() => {
container = new TestContainer()
service = container.bind(DocumentationService)
})
describe("Collection Documentation", () => {
it("should set and get collection documentation", () => {
const collectionId = "collection-123"
const documentation = "# Test Collection\nThis is a test collection."
service.setCollectionDocumentation(
collectionId,
documentation,
mockCollectionOptions
)
expect(service.getDocumentation("collection", collectionId)).toBe(
documentation
)
})
it("should store complete collection documentation item", () => {
const collectionId = "collection-123"
const documentation = "# Test Collection\nThis is a test collection."
service.setCollectionDocumentation(
collectionId,
documentation,
mockCollectionOptions
)
const item = service.getDocumentationItem(
"collection",
collectionId
) as CollectionDocumentationItem
expect(item).toEqual({
type: "collection",
id: collectionId,
documentation,
isTeamItem: false,
teamID: undefined,
pathOrID: "test-path",
collectionData: mockCollection,
})
})
it("should handle team collection documentation", () => {
const collectionId = "team-collection-789"
const documentation = "# Team Collection\nThis is a team collection."
service.setCollectionDocumentation(
collectionId,
documentation,
mockTeamCollectionOptions
)
const item = service.getDocumentationItem(
"collection",
collectionId
) as CollectionDocumentationItem
expect(item.isTeamItem).toBe(true)
expect(item.teamID).toBe("team-456")
})
it("should update existing collection documentation", () => {
const collectionId = "collection-123"
const originalDoc = "Original documentation"
const updatedDoc = "Updated documentation"
service.setCollectionDocumentation(
collectionId,
originalDoc,
mockCollectionOptions
)
service.setCollectionDocumentation(
collectionId,
updatedDoc,
mockCollectionOptions
)
expect(service.getDocumentation("collection", collectionId)).toBe(
updatedDoc
)
})
})
describe("Request Documentation", () => {
it("should set and get request documentation", () => {
const requestId = "request-456"
const documentation = "## Test Request\nThis is a test request."
service.setRequestDocumentation(
requestId,
documentation,
mockRequestOptions
)
expect(service.getDocumentation("request", requestId)).toBe(documentation)
})
it("should store complete request documentation item for personal requests", () => {
const requestId = "request-456"
const documentation = "## Test Request\nThis is a test request."
service.setRequestDocumentation(
requestId,
documentation,
mockRequestOptions
)
const item = service.getDocumentationItem(
"request",
requestId
) as RequestDocumentationItem
expect(item).toEqual({
type: "request",
id: requestId,
documentation,
isTeamItem: false,
teamID: undefined,
parentCollectionID: "collection-123",
folderPath: "test-folder",
requestID: undefined,
requestIndex: 0,
requestData: mockRequest,
})
})
it("should store complete request documentation item for team requests", () => {
const requestId = "team-request-789"
const documentation = "## Team Request\nThis is a team request."
service.setRequestDocumentation(
requestId,
documentation,
mockTeamRequestOptions
)
const item = service.getDocumentationItem(
"request",
requestId
) as RequestDocumentationItem
expect(item).toEqual({
type: "request",
id: requestId,
documentation,
isTeamItem: true,
teamID: "team-456",
parentCollectionID: "collection-123",
folderPath: "team-folder",
requestID: "request-789",
requestIndex: undefined,
requestData: mockRequest,
})
})
it("should get parent collection ID for request", () => {
const requestId = "request-456"
const documentation = "## Test Request\nThis is a test request."
service.setRequestDocumentation(
requestId,
documentation,
mockRequestOptions
)
expect(service.getParentCollectionID(requestId)).toBe("collection-123")
})
it("should return undefined for parent collection ID when request not found", () => {
expect(service.getParentCollectionID("non-existent")).toBeUndefined()
})
})
describe("Change Tracking", () => {
it("should track if there are changes", () => {
expect(service.hasChanges.value).toBe(false)
service.setCollectionDocumentation(
"collection-123",
"Test documentation",
mockCollectionOptions
)
expect(service.hasChanges.value).toBe(true)
})
it("should check if specific item has changes", () => {
const collectionId = "collection-123"
const requestId = "request-456"
expect(service.hasItemChanges("collection", collectionId)).toBe(false)
expect(service.hasItemChanges("request", requestId)).toBe(false)
service.setCollectionDocumentation(
collectionId,
"Test documentation",
mockCollectionOptions
)
expect(service.hasItemChanges("collection", collectionId)).toBe(true)
expect(service.hasItemChanges("request", requestId)).toBe(false)
})
it("should return correct changes count", () => {
expect(service.getChangesCount()).toBe(0)
service.setCollectionDocumentation(
"collection-123",
"Collection doc",
mockCollectionOptions
)
expect(service.getChangesCount()).toBe(1)
service.setRequestDocumentation(
"request-456",
"Request doc",
mockRequestOptions
)
expect(service.getChangesCount()).toBe(2)
})
it("should get all changed items", () => {
service.setCollectionDocumentation(
"collection-123",
"Collection doc",
mockCollectionOptions
)
service.setRequestDocumentation(
"request-456",
"Request doc",
mockRequestOptions
)
const changes = service.getChangedItems()
expect(changes).toHaveLength(2)
expect(changes.some((item) => item.type === "collection")).toBe(true)
expect(changes.some((item) => item.type === "request")).toBe(true)
})
})
describe("Item Management", () => {
beforeEach(() => {
// Set up some test data
service.setCollectionDocumentation(
"collection-123",
"Collection doc",
mockCollectionOptions
)
service.setRequestDocumentation(
"request-456",
"Request doc",
mockRequestOptions
)
})
it("should remove specific item", () => {
expect(service.hasItemChanges("collection", "collection-123")).toBe(true)
expect(service.getChangesCount()).toBe(2)
service.removeItem("collection", "collection-123")
expect(service.hasItemChanges("collection", "collection-123")).toBe(false)
expect(service.getChangesCount()).toBe(1)
})
it("should clear all changes", () => {
expect(service.getChangesCount()).toBe(2)
expect(service.hasChanges.value).toBe(true)
service.clearAll()
expect(service.getChangesCount()).toBe(0)
expect(service.hasChanges.value).toBe(false)
})
})
describe("Edge Cases", () => {
it("should return undefined for non-existent documentation", () => {
expect(
service.getDocumentation("collection", "non-existent")
).toBeUndefined()
expect(
service.getDocumentation("request", "non-existent")
).toBeUndefined()
})
it("should return undefined for non-existent documentation item", () => {
expect(
service.getDocumentationItem("collection", "non-existent")
).toBeUndefined()
expect(
service.getDocumentationItem("request", "non-existent")
).toBeUndefined()
})
it("should handle empty documentation strings", () => {
const collectionId = "collection-empty"
const emptyDoc = ""
service.setCollectionDocumentation(
collectionId,
emptyDoc,
mockCollectionOptions
)
expect(service.getDocumentation("collection", collectionId)).toBe(
emptyDoc
)
})
it("should handle documentation with special characters", () => {
const collectionId = "collection-special"
const specialDoc =
"# Test 🚀\n\n**Bold** _italic_ `code`\n\n- List item\n- Another item"
service.setCollectionDocumentation(
collectionId,
specialDoc,
mockCollectionOptions
)
expect(service.getDocumentation("collection", collectionId)).toBe(
specialDoc
)
})
it("should handle very long documentation", () => {
const collectionId = "collection-long"
const longDoc = "# Long Documentation\n" + "A".repeat(10000)
service.setCollectionDocumentation(
collectionId,
longDoc,
mockCollectionOptions
)
expect(service.getDocumentation("collection", collectionId)).toBe(longDoc)
})
it("should return undefined for parent collection ID when item is not a request", () => {
service.setCollectionDocumentation(
"collection-123",
"Collection doc",
mockCollectionOptions
)
// The key will be collection_collection-123, which won't match request_ prefix
expect(service.getParentCollectionID("collection-123")).toBeUndefined()
})
})
describe("Reactive Properties", () => {
it("should reactively update hasChanges computed property", () => {
expect(service.hasChanges.value).toBe(false)
service.setCollectionDocumentation(
"collection-123",
"Test doc",
mockCollectionOptions
)
expect(service.hasChanges.value).toBe(true)
service.clearAll()
expect(service.hasChanges.value).toBe(false)
})
})
})

View file

@ -0,0 +1,229 @@
import { Service } from "dioc"
import { reactive, computed } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
// Types for documentation
export type DocumentationType = "collection" | "request"
/**
* Base documentation item with common properties
*/
export interface BaseDocumentationItem {
id: string
documentation: string
isTeamItem: boolean
teamID?: string
}
/**
* Collection documentation item
*/
export interface CollectionDocumentationItem extends BaseDocumentationItem {
type: "collection"
/**
* The path (for personal collections) or ID (for team collections) of the collection
*/
pathOrID: string
collectionData: HoppCollection
}
/**
* Request documentation item (supports both team and personal requests)
*/
export interface RequestDocumentationItem extends BaseDocumentationItem {
type: "request"
parentCollectionID: string
folderPath: string
requestID?: string // For team requests
requestIndex?: number // For personal requests
requestData: HoppRESTRequest
}
export type DocumentationItem =
| CollectionDocumentationItem
| RequestDocumentationItem
/**
* Base options for setting documentation
*/
export interface BaseDocumentationOptions {
isTeamItem: boolean
teamID?: string
}
/**
* Options for setting collection documentation
*/
export interface SetCollectionDocumentationOptions
extends BaseDocumentationOptions {
/**
* The path (for personal collections) or ID (for team collections) of the collection
*/
pathOrID: string
collectionData: HoppCollection
}
/**
* Request documentation
*/
export interface SetRequestDocumentationOptions
extends BaseDocumentationOptions {
parentCollectionID: string
folderPath: string
requestID?: string // For team requests
requestIndex?: number // For personal requests
requestData: HoppRESTRequest
}
/**
* This service manages edited documentation for collections and requests.
* It temporarily stores the edited documentation in a map for efficient saving.
* So that multiple edits can be batched together.
*/
export class DocumentationService extends Service {
public static readonly ID = "DOCUMENTATION_SERVICE"
private editedDocumentation = reactive(new Map<string, DocumentationItem>())
/**
* Computed property to check if there are any unsaved changes
*/
public hasChanges = computed(() => this.editedDocumentation.size > 0)
/**
* Sets collection documentation
*/
public setCollectionDocumentation(
id: string,
documentation: string,
options: SetCollectionDocumentationOptions
): void {
const key = `collection_${id}`
const item: CollectionDocumentationItem = {
type: "collection",
id,
documentation,
isTeamItem: options.isTeamItem,
teamID: options.teamID,
pathOrID: options.pathOrID,
collectionData: options.collectionData,
}
this.editedDocumentation.set(key, item)
}
/**
* Sets request documentation
*/
public setRequestDocumentation(
id: string,
documentation: string,
options: SetRequestDocumentationOptions
): void {
const key = `request_${id}`
const item: RequestDocumentationItem = {
type: "request",
id,
documentation,
isTeamItem: options.isTeamItem,
teamID: options.teamID,
parentCollectionID: options.parentCollectionID,
folderPath: options.folderPath,
requestID: options.requestID, // Will be defined for team requests
requestIndex: options.requestIndex, // Will be defined for personal requests
requestData: options.requestData,
}
this.editedDocumentation.set(key, item)
}
/**
* Gets the documentation for a collection or request
* @param type The type of item ('collection' or 'request')
* @param id The ID of the collection or request
* @returns The documentation content or undefined if not found
*/
public getDocumentation(
type: DocumentationType,
id: string
): string | undefined {
const key = `${type}_${id}`
const stored = this.editedDocumentation.get(key)
return stored?.documentation
}
/**
* Gets the parent collection ID for a request documentation item
* @param id The ID of the request
* @returns The parent collection ID or undefined if not found or not a request
*/
public getParentCollectionID(id: string): string | undefined {
const key = `request_${id}`
const stored = this.editedDocumentation.get(key)
if (stored?.type === "request") {
return stored.parentCollectionID
}
return undefined
}
/**
* Gets the complete documentation item with all metadata
* @param type The type of item ('collection' or 'request')
* @param id The ID of the collection or request
* @returns The complete documentation item or undefined if not found
*/
public getDocumentationItem(
type: DocumentationType,
id: string
): DocumentationItem | undefined {
const key = `${type}_${id}`
return this.editedDocumentation.get(key)
}
/**
* Gets all changed items as an array
* @returns Array of all items with changes
*/
public getChangedItems(): DocumentationItem[] {
return Array.from(this.editedDocumentation.values())
}
/**
* Clears all edited documentation
*/
public clearAll(): void {
this.editedDocumentation.clear()
}
/**
* Removes a specific item from the edited documentation
* @param type The type of item ('collection' or 'request')
* @param id The ID of the collection or request
*/
public removeItem(type: DocumentationType, id: string): void {
const key = `${type}_${id}`
this.editedDocumentation.delete(key)
}
/**
* Checks if a specific item has changes
* @param type The type of item ('collection' or 'request')
* @param id The ID of the collection or request
* @returns True if the item has changes
*/
public hasItemChanges(type: DocumentationType, id: string): boolean {
const key = `${type}_${id}`
return this.editedDocumentation.has(key)
}
/**
* Gets the count of items with changes
* @returns Number of items with unsaved changes
*/
public getChangesCount(): number {
return this.editedDocumentation.size
}
}

View file

@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
{ {
v: 10, v: 11,
name: "Echo", name: "Echo",
requests: [ requests: [
{ {
@ -47,18 +47,20 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
}, },
requestVariables: [], requestVariables: [],
responses: {}, responses: {},
description: null,
}, },
], ],
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
folders: [], folders: [],
}, },
] ]
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
{ {
v: 10, v: 11,
name: "Echo", name: "Echo",
requests: [ requests: [
{ {
@ -77,6 +79,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
auth: { authType: "none", authActive: true }, auth: { authType: "none", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
folders: [], folders: [],
}, },
] ]
@ -173,6 +176,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
requestVariables: [], requestVariables: [],
v: RESTReqSchemaVersion, v: RESTReqSchemaVersion,
responses: {}, responses: {},
description: null,
}, },
responseMeta: { duration: 807, statusCode: 200 }, responseMeta: { duration: 807, statusCode: 200 },
star: false, star: false,
@ -240,6 +244,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRequestDocument> = {
body: { contentType: null, body: null }, body: { contentType: null, body: null },
requestVariables: [], requestVariables: [],
responses: {}, responses: {},
description: null,
_ref_id: "req_ref_id", _ref_id: "req_ref_id",
}, },
isDirty: false, isDirty: false,

View file

@ -472,6 +472,7 @@ export class PersistenceService extends Service {
if (result.success) { if (result.success) {
const translatedData = result.data.map(translateToNewRESTCollection) const translatedData = result.data.map(translateToNewRESTCollection)
setRESTCollections(translatedData) setRESTCollections(translatedData)
} else { } else {
console.error(`Failed with `, result.error, data) console.error(`Failed with `, result.error, data)

View file

@ -86,6 +86,7 @@ const SettingsDefSchema = z.object({
EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()), EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()),
ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()), ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()),
ENABLE_EXPERIMENTAL_DOCUMENTATION: z.optional(z.boolean()),
}) })
const HoppRESTRequestSchema = entityReference(HoppRESTRequest) const HoppRESTRequestSchema = entityReference(HoppRESTRequest)

View file

@ -1234,4 +1234,68 @@ export class TeamCollectionsService extends Service<void> {
return { auth, headers, variables } return { auth, headers, variables }
} }
private async waitForCollectionLoading(collectionID: string) {
while (this.loadingCollections.value.includes(collectionID)) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
/**
* Used to obtain the inherited auth and headers for a given folder path
* This function is async and will expand the collections if they are not expanded yet
* @param folderPath the path of the folder to cascade the auth from
* @returns the inherited auth and headers for the given folder path
*/
public async cascadeParentCollectionForPropertiesAsync(folderPath: string) {
if (!folderPath)
return {
auth: {
parentID: "",
parentName: "",
inheritedAuth: {
authType: "none",
authActive: true,
},
},
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: {
parentID: "",
parentName: "",
inheritedAuth: {
authType: "none",
authActive: true,
},
},
headers: [],
variables: [],
}
}
// Loop through the path and expand the collections if they are not expanded
for (let i = 0; i < path.length; i++) {
const parentFolder = findCollInTree(this.collections.value, path[i])
if (parentFolder) {
if (parentFolder.children === null) {
if (this.loadingCollections.value.includes(parentFolder.id)) {
await this.waitForCollectionLoading(parentFolder.id)
} else {
await this.expandCollection(parentFolder.id)
}
}
}
}
return this.cascadeParentCollectionForProperties(folderPath)
}
} }

View file

@ -65,6 +65,7 @@ export class TestRunnerService extends Service {
folders: [], folders: [],
requests: [], requests: [],
variables: [], variables: [],
description: collection.description ?? null,
} }
this.runTestCollection(tab, collection, options) this.runTestCollection(tab, collection, options)

View file

@ -10,6 +10,7 @@ import V7_VERSION from "./v/7"
import V8_VERSION from "./v/8" import V8_VERSION from "./v/8"
import V9_VERSION from "./v/9" import V9_VERSION from "./v/9"
import V10_VERSION from "./v/10" import V10_VERSION from "./v/10"
import V11_VERSION from "./v/11"
export { CollectionVariable } from "./v/10" export { CollectionVariable } from "./v/10"
@ -23,7 +24,7 @@ const versionedObject = z.object({
}) })
export const HoppCollection = createVersionedEntity({ export const HoppCollection = createVersionedEntity({
latestVersion: 10, latestVersion: 11,
versionMap: { versionMap: {
1: V1_VERSION, 1: V1_VERSION,
2: V2_VERSION, 2: V2_VERSION,
@ -35,6 +36,7 @@ export const HoppCollection = createVersionedEntity({
8: V8_VERSION, 8: V8_VERSION,
9: V9_VERSION, 9: V9_VERSION,
10: V10_VERSION, 10: V10_VERSION,
11: V11_VERSION,
}, },
getVersion(data) { getVersion(data) {
const versionCheck = versionedObject.safeParse(data) const versionCheck = versionedObject.safeParse(data)
@ -54,7 +56,7 @@ export type HoppCollectionVariable = InferredEntity<
typeof HoppCollection typeof HoppCollection
>["variables"][number] >["variables"][number]
export const CollectionSchemaVersion = 10 export const CollectionSchemaVersion = 11
/** /**
* Generates a Collection object. This ignores the version number object * Generates a Collection object. This ignores the version number object
@ -84,6 +86,8 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
const headers = x.headers ?? [] const headers = x.headers ?? []
const variables = x.variables ?? [] const variables = x.variables ?? []
const description = x.description ?? null
const obj = makeCollection({ const obj = makeCollection({
name, name,
folders, folders,
@ -91,10 +95,13 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
auth, auth,
headers, headers,
variables, variables,
description,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id
if (x._ref_id) obj._ref_id = x._ref_id if (x._ref_id) {
obj._ref_id = x._ref_id
}
return obj return obj
} }
@ -114,6 +121,8 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
const headers = x.headers ?? [] const headers = x.headers ?? []
const variables = x.variables ?? [] const variables = x.variables ?? []
const description = x.description ?? null
const obj = makeCollection({ const obj = makeCollection({
name, name,
folders, folders,
@ -121,10 +130,13 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
auth, auth,
headers, headers,
variables, variables,
description,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id
if (x._ref_id) obj._ref_id = x._ref_id if (x._ref_id) {
obj._ref_id = x._ref_id
}
return obj return obj
} }

View file

@ -0,0 +1,45 @@
import { defineVersion, entityRefUptoVersion } from "verzod"
import { z } from "zod"
import { HoppCollection } from ".."
import { v10_baseCollectionSchema } from "./10"
export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({
v: z.literal(11),
description: z.string().nullable().catch(null),
})
type Input = z.input<typeof v11_baseCollectionSchema> & {
folders: Input[]
}
type Output = z.output<typeof v11_baseCollectionSchema> & {
folders: Output[]
}
export const V11_SCHEMA = v11_baseCollectionSchema.extend({
folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 11))),
}) as z.ZodType<Output, z.ZodTypeDef, Input>
export default defineVersion({
initial: false,
schema: V11_SCHEMA,
up(old: z.infer<typeof V11_SCHEMA>) {
const result: z.infer<typeof V11_SCHEMA> = {
...old,
v: 11 as const,
description: old.description ?? null,
folders: old.folders.map((folder) => {
const result = HoppCollection.safeParseUpToVersion(folder, 11)
if (result.type !== "ok") {
throw new Error("Failed to migrate child collections")
}
return result.value
}),
}
return result
},
})

View file

@ -27,6 +27,7 @@ import V15_VERSION from "./v/15/index"
import V16_VERSION from "./v/16" import V16_VERSION from "./v/16"
import { HoppRESTRequestResponses } from "../rest-request-response" import { HoppRESTRequestResponses } from "../rest-request-response"
import { generateUniqueRefId } from "../utils/collection" import { generateUniqueRefId } from "../utils/collection"
import V17_VERSION from "./v/17"
export * from "./content-types" export * from "./content-types"
@ -77,7 +78,7 @@ const versionedObject = z.object({
}) })
export const HoppRESTRequest = createVersionedEntity({ export const HoppRESTRequest = createVersionedEntity({
latestVersion: 16, latestVersion: 17,
versionMap: { versionMap: {
0: V0_VERSION, 0: V0_VERSION,
1: V1_VERSION, 1: V1_VERSION,
@ -96,6 +97,7 @@ export const HoppRESTRequest = createVersionedEntity({
14: V14_VERSION, 14: V14_VERSION,
15: V15_VERSION, 15: V15_VERSION,
16: V16_VERSION, 16: V16_VERSION,
17: V17_VERSION,
}, },
getVersion(data) { getVersion(data) {
// For V1 onwards we have the v string storing the number // For V1 onwards we have the v string storing the number
@ -137,9 +139,10 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
), ),
responses: lodashIsEqualEq, responses: lodashIsEqualEq,
_ref_id: undefinedEq(S.Eq), _ref_id: undefinedEq(S.Eq),
description: lodashIsEqualEq,
}) })
export const RESTReqSchemaVersion = "16" export const RESTReqSchemaVersion = "17"
export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTParam = HoppRESTRequest["params"][number]
export type HoppRESTHeader = HoppRESTRequest["headers"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number]
@ -227,6 +230,10 @@ export function safelyExtractRESTRequest(
req.responses = result.data req.responses = result.data
} }
} }
if ("description" in x && typeof x.description === "string") {
req.description = x.description
}
} }
return req return req
@ -243,6 +250,7 @@ export function makeRESTRequest(
} }
export function getDefaultRESTRequest(): HoppRESTRequest { export function getDefaultRESTRequest(): HoppRESTRequest {
const ref_id = generateUniqueRefId("req")
return { return {
v: RESTReqSchemaVersion, v: RESTReqSchemaVersion,
endpoint: "https://echo.hoppscotch.io", endpoint: "https://echo.hoppscotch.io",
@ -262,7 +270,8 @@ export function getDefaultRESTRequest(): HoppRESTRequest {
}, },
requestVariables: [], requestVariables: [],
responses: {}, responses: {},
_ref_id: generateUniqueRefId("req"), _ref_id: ref_id,
description: null,
} }
} }

View file

@ -0,0 +1,22 @@
import { z } from "zod"
import { defineVersion } from "verzod"
import { V16_SCHEMA } from "./16"
export const V17_SCHEMA = V16_SCHEMA.extend({
v: z.literal("17"),
description: z.string().nullable().catch(null),
})
const V17_VERSION = defineVersion({
schema: V17_SCHEMA,
initial: false,
up(old: z.infer<typeof V17_SCHEMA>) {
return {
...old,
v: "17" as const,
description: old.description ?? null,
}
},
})
export default V17_VERSION

View file

@ -136,11 +136,12 @@ function exportedCollectionToHoppCollection(
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
return { return {
id: restCollection.id, id: restCollection.id,
v: 10, v: 11,
name: restCollection.name, name: restCollection.name,
folders: restCollection.folders.map((folder) => folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -165,6 +166,7 @@ function exportedCollectionToHoppCollection(
testScript, testScript,
requestVariables, requestVariables,
responses, responses,
description,
} = request } = request
const resolvedParams = addDescriptionField(params) const resolvedParams = addDescriptionField(params)
@ -184,11 +186,13 @@ function exportedCollectionToHoppCollection(
preRequestScript, preRequestScript,
testScript, testScript,
responses, responses,
description,
} }
}), }),
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
} }
} else { } else {
const gqlCollection = collection as ExportedUserCollectionGQL const gqlCollection = collection as ExportedUserCollectionGQL
@ -200,11 +204,12 @@ function exportedCollectionToHoppCollection(
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
return { return {
id: gqlCollection.id, id: gqlCollection.id,
v: 10, v: 11,
name: gqlCollection.name, name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) => folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -233,6 +238,7 @@ function exportedCollectionToHoppCollection(
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
} }
} }
} }
@ -382,6 +388,7 @@ function setupUserCollectionCreatedSubscription() {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
runDispatchWithOutSyncing(() => { runDispatchWithOutSyncing(() => {
@ -390,19 +397,21 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
: addRESTCollection({ : addRESTCollection({
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
const localIndex = collectionStore.value.state.length - 1 const localIndex = collectionStore.value.state.length - 1
@ -605,13 +614,14 @@ function setupUserCollectionDuplicatedSubscription() {
) )
// Incoming data transformed to the respective internal representations // Incoming data transformed to the respective internal representations
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data != "null" data && data != "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
const folders = transformDuplicatedCollections(childCollectionsJSONStr) const folders = transformDuplicatedCollections(childCollectionsJSONStr)
@ -626,10 +636,11 @@ function setupUserCollectionDuplicatedSubscription() {
name, name,
folders, folders,
requests, requests,
v: 10, v: 11,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
// only folders will have parent collection id // only folders will have parent collection id
@ -1093,13 +1104,14 @@ function transformDuplicatedCollections(
requests: userRequests, requests: userRequests,
title: name, title: name,
}) => { }) => {
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data !== "null" data && data !== "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
const folders = transformDuplicatedCollections(childCollectionsJSONStr) const folders = transformDuplicatedCollections(childCollectionsJSONStr)
@ -1111,10 +1123,11 @@ function transformDuplicatedCollections(
name, name,
folders, folders,
requests, requests,
v: 10, v: 11,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
} }
) )

View file

@ -61,6 +61,7 @@ const recursivelySyncCollections = async (
}, },
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
description: collection.description ?? null,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
} }
const res = await createRESTRootUserCollection( const res = await createRESTRootUserCollection(
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -88,6 +90,7 @@ const recursivelySyncCollections = async (
collection.auth = returnedData.auth collection.auth = returnedData.auth
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
} else { } else {
parentCollectionID = undefined parentCollectionID = undefined
@ -102,6 +105,7 @@ const recursivelySyncCollections = async (
}, },
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
description: collection.description ?? null,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
} }
@ -124,6 +128,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -132,6 +137,7 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
childCollectionId, childCollectionId,
@ -177,6 +183,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
}, },
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
description: collection.description ?? null,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
} }
@ -254,6 +261,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description,
} }
if (collectionID) { if (collectionID) {
@ -335,6 +343,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: folder.headers, headers: folder.headers,
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description,
} }
if (folderID) { if (folderID) {

View file

@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection(
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
return { return {
id: restCollection.id, id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 10, v: 11,
name: restCollection.name, name: restCollection.name,
folders: restCollection.folders.map((folder) => folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -170,6 +171,8 @@ function exportedCollectionToHoppCollection(
testScript, testScript,
requestVariables, requestVariables,
responses, responses,
description,
_ref_id,
} = request } = request
const resolvedParams = addDescriptionField(params) const resolvedParams = addDescriptionField(params)
@ -189,11 +192,14 @@ function exportedCollectionToHoppCollection(
preRequestScript, preRequestScript,
testScript, testScript,
responses, responses,
description: description ?? null,
_ref_id: _ref_id ?? generateUniqueRefId("req"),
} }
}), }),
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
} }
} else { } else {
const gqlCollection = collection as ExportedUserCollectionGQL const gqlCollection = collection as ExportedUserCollectionGQL
@ -206,12 +212,13 @@ function exportedCollectionToHoppCollection(
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
return { return {
id: gqlCollection.id, id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 10, v: 11,
name: gqlCollection.name, name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) => folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -240,6 +247,7 @@ function exportedCollectionToHoppCollection(
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
} }
} }
} }
@ -398,21 +406,23 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
: addRESTCollection({ : addRESTCollection({
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
const localIndex = collectionStore.value.state.length - 1 const localIndex = collectionStore.value.state.length - 1
@ -615,13 +625,14 @@ function setupUserCollectionDuplicatedSubscription() {
) )
// Incoming data transformed to the respective internal representations // Incoming data transformed to the respective internal representations
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data != "null" data && data != "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
// Duplicated collection will have a unique ref id // Duplicated collection will have a unique ref id
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -638,11 +649,12 @@ function setupUserCollectionDuplicatedSubscription() {
name, name,
folders, folders,
requests, requests,
v: 10, v: 11,
_ref_id, _ref_id,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
// only folders will have parent collection id // only folders will have parent collection id
@ -1106,13 +1118,14 @@ function transformDuplicatedCollections(
requests: userRequests, requests: userRequests,
title: name, title: name,
}) => { }) => {
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data !== "null" data && data !== "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -1127,10 +1140,11 @@ function transformDuplicatedCollections(
folders, folders,
requests, requests,
_ref_id, _ref_id,
v: 10, v: 11,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
} }
) )

View file

@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
return { return {
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
const res = await createRESTRootUserCollection( const res = await createRESTRootUserCollection(
collection.name, collection.name,
@ -99,6 +101,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -106,6 +109,7 @@ const recursivelySyncCollections = async (
collection.auth = returnedData.auth collection.auth = returnedData.auth
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
} else { } else {
parentCollectionID = undefined parentCollectionID = undefined
@ -120,6 +124,7 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
const res = await createRESTChildUserCollection( const res = await createRESTChildUserCollection(
@ -141,6 +146,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -149,6 +155,7 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
childCollectionId, childCollectionId,
@ -260,6 +267,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
if (collectionID) { if (collectionID) {
@ -342,6 +350,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: folder.headers, headers: folder.headers,
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description ?? null,
} }
if (folderID) { if (folderID) {
updateUserCollection(folderID, folderName, JSON.stringify(data)) updateUserCollection(folderID, folderName, JSON.stringify(data))

View file

@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection(
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
return { return {
id: restCollection.id, id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 10, v: 11,
name: restCollection.name, name: restCollection.name,
folders: restCollection.folders.map((folder) => folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -170,6 +171,7 @@ function exportedCollectionToHoppCollection(
testScript, testScript,
requestVariables, requestVariables,
responses, responses,
description,
_ref_id, _ref_id,
} = request } = request
@ -190,9 +192,11 @@ function exportedCollectionToHoppCollection(
preRequestScript, preRequestScript,
testScript, testScript,
responses, responses,
description: description ?? null,
_ref_id: _ref_id ?? generateUniqueRefId("req"), _ref_id: _ref_id ?? generateUniqueRefId("req"),
} }
}), }),
description: data.description ?? null,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
@ -208,12 +212,13 @@ function exportedCollectionToHoppCollection(
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
return { return {
id: gqlCollection.id, id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 10, v: 11,
name: gqlCollection.name, name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) => folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -242,6 +247,7 @@ function exportedCollectionToHoppCollection(
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
} }
} }
} }
@ -392,6 +398,7 @@ function setupUserCollectionCreatedSubscription() {
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
} }
runDispatchWithOutSyncing(() => { runDispatchWithOutSyncing(() => {
@ -400,21 +407,23 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
: addRESTCollection({ : addRESTCollection({
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 10, v: 11,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null,
}) })
const localIndex = collectionStore.value.state.length - 1 const localIndex = collectionStore.value.state.length - 1
@ -617,13 +626,14 @@ function setupUserCollectionDuplicatedSubscription() {
) )
// Incoming data transformed to the respective internal representations // Incoming data transformed to the respective internal representations
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data != "null" data && data != "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
// Duplicated collection will have a unique ref id // Duplicated collection will have a unique ref id
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -640,11 +650,12 @@ function setupUserCollectionDuplicatedSubscription() {
name, name,
folders, folders,
requests, requests,
v: 10, v: 11,
_ref_id, _ref_id,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
// only folders will have parent collection id // only folders will have parent collection id
@ -1108,13 +1119,14 @@ function transformDuplicatedCollections(
requests: userRequests, requests: userRequests,
title: name, title: name,
}) => { }) => {
const { auth, headers, variables } = const { auth, headers, variables, description } =
data && data !== "null" data && data !== "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
description: null,
} }
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -1129,10 +1141,11 @@ function transformDuplicatedCollections(
folders, folders,
requests, requests,
_ref_id, _ref_id,
v: 10, v: 11,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null,
} }
} }
) )

View file

@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
return { return {
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
const res = await createRESTRootUserCollection( const res = await createRESTRootUserCollection(
collection.name, collection.name,
@ -99,6 +101,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -106,6 +109,7 @@ const recursivelySyncCollections = async (
collection.auth = returnedData.auth collection.auth = returnedData.auth
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
parentCollectionID, parentCollectionID,
`${collectionPath}` `${collectionPath}`
@ -123,6 +127,7 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
const res = await createRESTChildUserCollection( const res = await createRESTChildUserCollection(
@ -144,6 +149,7 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -152,6 +158,7 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
childCollectionId, childCollectionId,
@ -263,6 +270,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
} }
if (collectionID) { if (collectionID) {
@ -346,6 +354,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: folder.headers, headers: folder.headers,
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description,
} }
if (folderID) { if (folderID) {
updateUserCollection(folderID, folderName, JSON.stringify(data)) updateUserCollection(folderID, folderName, JSON.stringify(data))

View file

@ -596,6 +596,9 @@ importers:
dioc: dioc:
specifier: 3.0.2 specifier: 3.0.2
version: 3.0.2(vue@3.5.22(typescript@5.9.3)) version: 3.0.2(vue@3.5.22(typescript@5.9.3))
dompurify:
specifier: 3.3.0
version: 3.3.0
esprima: esprima:
specifier: 4.0.1 specifier: 4.0.1
version: 4.0.1 version: 4.0.1
@ -620,6 +623,12 @@ importers:
hawk: hawk:
specifier: 9.0.2 specifier: 9.0.2
version: 9.0.2 version: 9.0.2
highlight.js:
specifier: 11.11.1
version: 11.11.1
highlightjs-curl:
specifier: 1.3.0
version: 1.3.0
insomnia-importers: insomnia-importers:
specifier: 3.6.0 specifier: 3.6.0
version: 3.6.0(openapi-types@12.1.3) version: 3.6.0(openapi-types@12.1.3)
@ -8311,6 +8320,9 @@ packages:
dompurify@3.2.7: dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.0:
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
domutils@2.8.0: domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
@ -9415,6 +9427,13 @@ packages:
header-case@2.0.4: header-case@2.0.4:
resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
highlightjs-curl@1.3.0:
resolution: {integrity: sha512-50UEfZq1KR0Lfk2Tr6xb/MUIZH3h10oNC0OTy9g7WELcs5Fgy/mKN1vEhuKTkKbdo8vr5F9GXstu2eLhApfQ3A==}
hono@4.10.3: hono@4.10.3:
resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
@ -22214,6 +22233,10 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
dompurify@3.3.0:
optionalDependencies:
'@types/trusted-types': 2.0.7
domutils@2.8.0: domutils@2.8.0:
dependencies: dependencies:
dom-serializer: 1.4.1 dom-serializer: 1.4.1
@ -23724,6 +23747,10 @@ snapshots:
capital-case: 1.0.4 capital-case: 1.0.4
tslib: 2.8.1 tslib: 2.8.1
highlight.js@11.11.1: {}
highlightjs-curl@1.3.0: {}
hono@4.10.3: {} hono@4.10.3: {}
hookable@5.5.3: {} hookable@5.5.3: {}