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:
parent
ce026d5cef
commit
e63bfe3723
85 changed files with 9889 additions and 128 deletions
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -296,6 +296,21 @@ model MockServerActivity {
|
|||
@@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 {
|
||||
USER
|
||||
TEAM
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||
import { PubSubModule } from './pubsub/pubsub.module';
|
||||
import { SortModule } from './orchestration/sort/sort.module';
|
||||
import { MockServerModule } from './mock-server/mock-server.module';
|
||||
import { PublishedDocsModule } from './published-docs/published-docs.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -126,6 +127,7 @@ import { MockServerModule } from './mock-server/mock-server.module';
|
|||
InfraTokenModule,
|
||||
SortModule,
|
||||
MockServerModule,
|
||||
PublishedDocsModule,
|
||||
],
|
||||
providers: [
|
||||
GQLComplexityPlugin,
|
||||
|
|
|
|||
|
|
@ -928,3 +928,34 @@ export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found';
|
|||
*/
|
||||
export const 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';
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { InfraTokenResolver } from './infra-token/infra-token.resolver';
|
|||
import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver';
|
||||
import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver';
|
||||
import { MockServerResolver } from './mock-server/mock-server.resolver';
|
||||
import { PublishedDocsResolver } from './published-docs/published-docs.resolver';
|
||||
|
||||
/**
|
||||
* All the resolvers present in the application.
|
||||
|
|
@ -68,6 +69,7 @@ const RESOLVERS = [
|
|||
SortUserCollectionResolver,
|
||||
SortTeamCollectionResolver,
|
||||
MockServerResolver,
|
||||
PublishedDocsResolver,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 { TeamAccessRole } from 'src/team/team.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { MockServerAnalyticsService } from './mock-server-analytics.service';
|
||||
|
||||
@Resolver(() => MockServer)
|
||||
export class MockServerResolver {
|
||||
constructor(
|
||||
private readonly mockServerService: MockServerService,
|
||||
private readonly mockServerAnalyticsService: MockServerAnalyticsService,
|
||||
) {}
|
||||
constructor(private readonly mockServerService: MockServerService) {}
|
||||
|
||||
// Resolve Fields
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -106,15 +106,19 @@ export class TeamCollectionService {
|
|||
*
|
||||
* @param teamID The Team 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
|
||||
*/
|
||||
async exportCollectionToJSONObject(
|
||||
teamID: string,
|
||||
collectionID: string,
|
||||
withChildren: boolean = true,
|
||||
): Promise<E.Right<CollectionFolder> | E.Left<string>> {
|
||||
const collection = await this.getCollection(collectionID);
|
||||
if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID);
|
||||
|
||||
const childrenCollectionObjects = [];
|
||||
if (withChildren) {
|
||||
const childrenCollection = await this.prisma.teamCollection.findMany({
|
||||
where: {
|
||||
teamID,
|
||||
|
|
@ -125,13 +129,13 @@ export class TeamCollectionService {
|
|||
},
|
||||
});
|
||||
|
||||
const childrenCollectionObjects = [];
|
||||
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({
|
||||
where: {
|
||||
|
|
@ -146,9 +150,17 @@ export class TeamCollectionService {
|
|||
const data = transformCollectionData(collection.right.data);
|
||||
|
||||
const result: CollectionFolder = {
|
||||
id: collection.right.id,
|
||||
name: collection.right.title,
|
||||
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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,34 @@
|
|||
// This interface defines how data will be received from the app when we are importing Hoppscotch collections
|
||||
export interface CollectionFolder {
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
// 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;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of subfolders',
|
||||
type: () => [CollectionFolder],
|
||||
})
|
||||
folders: CollectionFolder[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of requests in the collection folder',
|
||||
type: [Object],
|
||||
})
|
||||
requests: any[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Name of the collection folder',
|
||||
example: 'My Collection Folder',
|
||||
})
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Additional data for the collection folder',
|
||||
type: String,
|
||||
})
|
||||
data?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -868,16 +868,20 @@ export class UserCollectionService {
|
|||
*
|
||||
* @param userUID The User UID
|
||||
* @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
|
||||
*/
|
||||
async exportUserCollectionToJSONObject(
|
||||
userUID: string,
|
||||
collectionID: string,
|
||||
withChildren: boolean = true,
|
||||
): Promise<E.Left<string> | E.Right<CollectionFolder>> {
|
||||
// Get Collection details
|
||||
const collection = await this.getUserCollection(collectionID);
|
||||
if (E.isLeft(collection)) return E.left(collection.left);
|
||||
|
||||
const childrenCollectionObjects: CollectionFolder[] = [];
|
||||
if (withChildren) {
|
||||
// Get all child collections whose parentID === collectionID
|
||||
const childCollectionList = await this.prisma.userCollection.findMany({
|
||||
where: {
|
||||
|
|
@ -890,7 +894,6 @@ export class UserCollectionService {
|
|||
});
|
||||
|
||||
// Create a list of child collection and request data ready for export
|
||||
const childrenCollectionObjects: CollectionFolder[] = [];
|
||||
for (const coll of childCollectionList) {
|
||||
const result = await this.exportUserCollectionToJSONObject(
|
||||
userUID,
|
||||
|
|
@ -900,6 +903,7 @@ export class UserCollectionService {
|
|||
|
||||
childrenCollectionObjects.push(result.right);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all child requests that belong to collectionID
|
||||
const requests = await this.prisma.userRequest.findMany({
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOC
|
|||
collectionID: "clx1ldkzs005t10f8rp5u60q7",
|
||||
teamID: "clws3hg58000011o8h07glsb1",
|
||||
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: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -190,6 +191,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -221,6 +223,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -245,6 +248,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
|
|||
preRequestScript: "",
|
||||
requestVariables: [],
|
||||
responses: {},
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
|
|
@ -268,6 +272,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -494,7 +499,7 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MO
|
|||
collectionID: "clx1f86hv000010f8szcfya0t",
|
||||
teamID: "clws3hg58000011o8h07glsb1",
|
||||
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[] =
|
||||
[
|
||||
{
|
||||
v: 10,
|
||||
v: CollectionSchemaVersion,
|
||||
id: "clx1f86hv000010f8szcfya0t",
|
||||
name: "Multiple child collections with authorization, headers and variables set at each level",
|
||||
folders: [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fjgah000110f8a5bs68gd",
|
||||
name: "folder-1",
|
||||
folders: [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fjwmm000410f8l1gkkr1a",
|
||||
name: "folder-11",
|
||||
folders: [],
|
||||
|
|
@ -561,9 +566,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fjyxm000510f8pv90dt43",
|
||||
name: "folder-12",
|
||||
folders: [],
|
||||
|
|
@ -627,9 +633,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fk1cv000610f88kc3aupy",
|
||||
name: "folder-13",
|
||||
folders: [],
|
||||
|
|
@ -711,6 +718,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -755,14 +763,15 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fjk9o000210f8j0573pls",
|
||||
name: "folder-2",
|
||||
folders: [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fk516000710f87sfpw6bo",
|
||||
name: "folder-21",
|
||||
folders: [],
|
||||
|
|
@ -808,9 +817,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fk72t000810f8gfwkpi5y",
|
||||
name: "folder-22",
|
||||
folders: [],
|
||||
|
|
@ -874,9 +884,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fk95g000910f8bunhaoo8",
|
||||
name: "folder-23",
|
||||
folders: [],
|
||||
|
|
@ -945,6 +956,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -995,15 +1007,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1fjmlq000310f86o4d3w2o",
|
||||
name: "folder-3",
|
||||
folders: [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1iwq0p003e10f8u8zg0p85",
|
||||
name: "folder-31",
|
||||
folders: [],
|
||||
|
|
@ -1049,9 +1062,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1izut7003m10f894ip59zg",
|
||||
name: "folder-32",
|
||||
folders: [],
|
||||
|
|
@ -1115,9 +1129,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
id: "clx1j2ka9003q10f8cdbzpgpg",
|
||||
name: "folder-33",
|
||||
folders: [],
|
||||
|
|
@ -1186,6 +1201,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -1249,6 +1265,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
|
|
@ -1272,6 +1289,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
preRequestScript: "",
|
||||
requestVariables: [],
|
||||
responses: {},
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
|
|
@ -1302,6 +1320,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -1408,6 +1427,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: CollectionSchemaVersion,
|
||||
|
|
@ -1455,6 +1475,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: CollectionSchemaVersion,
|
||||
|
|
@ -1468,6 +1489,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
},
|
||||
{
|
||||
v: CollectionSchemaVersion,
|
||||
|
|
@ -1495,6 +1517,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
|
|||
secret: false,
|
||||
},
|
||||
],
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
requests: [],
|
||||
|
|
@ -1504,6 +1527,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -178,12 +178,14 @@ export const transformWorkspaceCollections = (
|
|||
auth?: HoppRESTAuth;
|
||||
headers?: HoppRESTHeaders;
|
||||
variables: HoppCollectionVariable[];
|
||||
description: string | null;
|
||||
} = data ? JSON.parse(data) : {};
|
||||
|
||||
const {
|
||||
auth = { authType: "inherit", authActive: true },
|
||||
headers = [],
|
||||
variables = [],
|
||||
description = null,
|
||||
} = parsedData;
|
||||
|
||||
const transformedAuth = transformAuth(auth);
|
||||
|
|
@ -208,6 +210,7 @@ export const transformWorkspaceCollections = (
|
|||
auth: transformedAuth,
|
||||
headers: transformedHeaders,
|
||||
variables: filteredCollectionVariables,
|
||||
description,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -406,8 +406,149 @@
|
|||
"variable": "Variable {count}"
|
||||
},
|
||||
"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_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": {
|
||||
"activity_logs": "No activity logs found",
|
||||
|
|
@ -1084,6 +1225,7 @@
|
|||
"ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...",
|
||||
"experimental_scripting_sandbox": "Experimental scripting sandbox",
|
||||
"enable_experimental_mock_servers": "Enable Mock Servers",
|
||||
"enable_experimental_documentation": "Enable Documentation",
|
||||
"sync": "Synchronise",
|
||||
"sync_collections": "Collections",
|
||||
"sync_description": "These settings are synced to cloud.",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"buffer": "6.0.3",
|
||||
"cookie-es": "2.0.0",
|
||||
"dioc": "3.0.2",
|
||||
"dompurify": "3.3.0",
|
||||
"esprima": "4.0.1",
|
||||
"events": "3.3.0",
|
||||
"fp-ts": "2.16.11",
|
||||
|
|
@ -71,6 +72,8 @@
|
|||
"graphql-language-service-interface": "2.10.2",
|
||||
"graphql-tag": "2.12.6",
|
||||
"hawk": "9.0.2",
|
||||
"highlight.js": "11.11.1",
|
||||
"highlightjs-curl": "1.3.0",
|
||||
"insomnia-importers": "3.6.0",
|
||||
"io-ts": "2.2.22",
|
||||
"js-md5": "0.8.3",
|
||||
|
|
|
|||
37
packages/hoppscotch-common/src/components.d.ts
vendored
37
packages/hoppscotch-common/src/components.d.ts
vendored
|
|
@ -50,6 +50,25 @@ declare module 'vue' {
|
|||
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
|
||||
CollectionsAddRequest: typeof import('./components/collections/AddRequest.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']
|
||||
CollectionsEditFolder: typeof import('./components/collections/EditFolder.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']
|
||||
CookiesAllModal: typeof import('./components/cookies/AllModal.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']
|
||||
EmbedsHeader: typeof import('./components/embeds/Header.vue')['default']
|
||||
EmbedsRequest: typeof import('./components/embeds/Request.vue')['default']
|
||||
|
|
@ -133,6 +155,7 @@ declare module 'vue' {
|
|||
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
|
||||
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
||||
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
|
||||
HoppSmartIcon: typeof import('@hoppscotch/ui')['HoppSmartIcon']
|
||||
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
|
||||
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
|
||||
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
||||
|
|
@ -211,20 +234,26 @@ declare module 'vue' {
|
|||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['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']
|
||||
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['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']
|
||||
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']
|
||||
IconLucideLightbulb: typeof import('~icons/lucide/lightbulb')['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']
|
||||
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideTerminal: typeof import('~icons/lucide/terminal')['default']
|
||||
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@
|
|||
@keyup.m="
|
||||
isMockServerVisible && mockServerAction?.$el.click()
|
||||
"
|
||||
@keyup.i="documentationAction?.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<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
|
||||
ref="propertiesAction"
|
||||
:icon="IconSettings2"
|
||||
|
|
@ -301,6 +315,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useDocumentationVisibility } from "~/composables/documentationVisibility"
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
|
|
@ -324,6 +339,7 @@ import IconServer from "~icons/lucide/server"
|
|||
import IconSettings2 from "~icons/lucide/settings-2"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconArrowUpDown from "~icons/lucide/arrow-up-down"
|
||||
import IconBook from "~icons/lucide/book"
|
||||
import { CurrentSortValuesService } from "~/services/current-sort.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { useMockServerStatus } from "~/composables/mockServer"
|
||||
|
|
@ -380,6 +396,7 @@ const emit = defineEmits<{
|
|||
(event: "edit-collection"): void
|
||||
(event: "edit-properties"): void
|
||||
(event: "duplicate-collection"): void
|
||||
(event: "open-documentation"): void
|
||||
(event: "export-data"): void
|
||||
(event: "remove-collection"): void
|
||||
(event: "create-mock-server"): void
|
||||
|
|
@ -411,6 +428,9 @@ const options = ref<TippyComponent | null>(null)
|
|||
const propertiesAction = ref<HTMLButtonElement | null>(null)
|
||||
const runCollectionAction = ref<HTMLButtonElement | null>(null)
|
||||
const sortAction = ref<HTMLButtonElement | null>(null)
|
||||
const documentationAction = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const { isDocumentationVisible } = useDocumentationVisibility()
|
||||
|
||||
const dragging = ref(false)
|
||||
const ordering = ref(false)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ function translateToTeamCollectionFormat(x: HoppCollection) {
|
|||
auth: x.auth,
|
||||
headers: x.headers,
|
||||
variables: x.variables,
|
||||
description: x.description,
|
||||
}
|
||||
|
||||
const obj = {
|
||||
|
|
@ -193,6 +194,7 @@ function translateToPersonalCollectionFormat(x: HoppCollection) {
|
|||
auth: x.auth,
|
||||
headers: x.headers,
|
||||
variables: x.variables,
|
||||
description: x.description,
|
||||
}
|
||||
|
||||
const obj = {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,14 @@
|
|||
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="
|
||||
node.data.type === 'collections' &&
|
||||
emit('edit-properties', {
|
||||
|
|
@ -189,6 +197,14 @@
|
|||
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="
|
||||
node.data.type === 'folders' &&
|
||||
emit('edit-properties', {
|
||||
|
|
@ -279,6 +295,15 @@
|
|||
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="
|
||||
emit('duplicate-response', {
|
||||
folderPath: node.data.data.parentIndex,
|
||||
|
|
@ -559,6 +584,23 @@ const emit = defineEmits<{
|
|||
collectionSyncID?: string
|
||||
}
|
||||
): 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",
|
||||
payload: {
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@
|
|||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!hasNoTeamAccess" class="flex">
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-if="!saveRequest"
|
||||
v-if="!saveRequest && !hasNoTeamAccess"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
|
|
@ -102,9 +102,11 @@
|
|||
@keyup.d="duplicate?.$el.click()"
|
||||
@keyup.delete="deleteAction?.$el.click()"
|
||||
@keyup.s="shareAction?.$el.click()"
|
||||
@keyup.i="documentationAction?.$el.click()"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-if="!hasNoTeamAccess"
|
||||
ref="edit"
|
||||
:icon="IconEdit"
|
||||
:label="t('action.edit')"
|
||||
|
|
@ -117,6 +119,7 @@
|
|||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-if="!hasNoTeamAccess"
|
||||
ref="duplicate"
|
||||
:icon="IconCopy"
|
||||
:label="t('action.duplicate')"
|
||||
|
|
@ -129,6 +132,20 @@
|
|||
"
|
||||
/>
|
||||
<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"
|
||||
:icon="IconShare2"
|
||||
:label="t('action.share')"
|
||||
|
|
@ -141,6 +158,7 @@
|
|||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-if="!hasNoTeamAccess"
|
||||
ref="deleteAction"
|
||||
:icon="IconTrash2"
|
||||
:label="t('action.delete')"
|
||||
|
|
@ -211,9 +229,11 @@ import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
|||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import IconArrowRight from "~icons/lucide/chevron-right"
|
||||
import IconArrowDown from "~icons/lucide/chevron-down"
|
||||
import IconBook from "~icons/lucide/book"
|
||||
import { ref, PropType, watch, computed } from "vue"
|
||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useDocumentationVisibility } from "~/composables/documentationVisibility"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import {
|
||||
changeCurrentReorderStatus,
|
||||
|
|
@ -293,6 +313,7 @@ const emit = defineEmits<{
|
|||
(event: "edit-request"): void
|
||||
(event: "edit-response", payload: ResponsePayload): void
|
||||
(event: "duplicate-request"): void
|
||||
(event: "open-request-documentation"): void
|
||||
(event: "remove-request"): void
|
||||
(event: "select-request"): void
|
||||
(event: "share-request"): void
|
||||
|
|
@ -311,6 +332,9 @@ const deleteAction = ref<HTMLButtonElement | null>(null)
|
|||
const options = ref<TippyComponent | null>(null)
|
||||
const duplicate = ref<HTMLButtonElement | null>(null)
|
||||
const shareAction = ref<HTMLButtonElement | null>(null)
|
||||
const documentationAction = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const { isDocumentationVisible } = useDocumentationVisibility()
|
||||
|
||||
const dragging = ref(false)
|
||||
const ordering = ref(false)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,14 @@
|
|||
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="
|
||||
node.data.type === 'collections' &&
|
||||
emit('create-mock-server', {
|
||||
|
|
@ -226,6 +234,14 @@
|
|||
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="
|
||||
node.data.type === 'folders' &&
|
||||
emit('export-data', node.data.data.data)
|
||||
|
|
@ -309,6 +325,15 @@
|
|||
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="
|
||||
emit('edit-response', {
|
||||
folderPath: node.data.data.parentIndex,
|
||||
|
|
@ -657,6 +682,23 @@ const emit = defineEmits<{
|
|||
}
|
||||
): 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",
|
||||
payload: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -53,6 +53,8 @@
|
|||
displayModalImportExport(true, 'my-collections')
|
||||
"
|
||||
@duplicate-collection="duplicateCollection"
|
||||
@open-documentation="openDocumentation"
|
||||
@open-request-documentation="openRequestDocumentation"
|
||||
@duplicate-request="duplicateRequest"
|
||||
@duplicate-response="duplicateResponse"
|
||||
@edit-properties="editProperties"
|
||||
|
|
@ -105,6 +107,8 @@
|
|||
@edit-request="editRequest"
|
||||
@edit-response="editResponse"
|
||||
@edit-properties="editProperties"
|
||||
@open-documentation="openDocumentation"
|
||||
@open-request-documentation="openRequestDocumentation"
|
||||
@create-mock-server="createTeamMockServer"
|
||||
@export-data="exportData"
|
||||
@expand-team-collection="expandTeamCollection"
|
||||
|
|
@ -209,12 +213,33 @@
|
|||
collectionsType.type === 'team-collections' && hasTeamWriteAccess
|
||||
"
|
||||
:has-team-write-access="
|
||||
collectionsType.type === 'team-collections' ? hasTeamWriteAccess : true
|
||||
hasTeamWriteAccess || collectionsType.type === 'my-collections'
|
||||
"
|
||||
source="REST"
|
||||
@hide-modal="displayModalEditProperties(false)"
|
||||
@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` -->
|
||||
<HttpTestRunnerModal
|
||||
|
|
@ -254,6 +279,7 @@ import { useReadonlyStream } from "~/composables/stream"
|
|||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CollectionDataProps,
|
||||
getCompleteCollectionTree,
|
||||
teamCollToHoppRESTColl,
|
||||
} from "~/helpers/backend/helpers"
|
||||
|
|
@ -369,18 +395,23 @@ const collectionsType = ref<CollectionType>({
|
|||
|
||||
// Collection Data
|
||||
const editingCollection = ref<HoppCollection | TeamCollection | null>(null)
|
||||
const editingCollectionIsTeam = ref<boolean>(false)
|
||||
const editingCollectionName = ref<string | null>(null)
|
||||
const editingCollectionIndex = ref<number | null>(null)
|
||||
const editingCollectionID = ref<string | null>(null)
|
||||
const editingCollectionPath = ref<string | null>(null)
|
||||
|
||||
const editingFolder = ref<HoppCollection | TeamCollection | null>(null)
|
||||
const editingFolderName = ref<string | null>(null)
|
||||
const editingFolderPath = ref<string | null>(null)
|
||||
|
||||
const editingRequest = ref<HoppRESTRequest | null>(null)
|
||||
const editingRequestName = ref("")
|
||||
const editingResponseName = ref("")
|
||||
const editingResponseOldName = ref("")
|
||||
const editingRequestIndex = ref<number | null>(null)
|
||||
const editingRequestID = ref<string | null>(null)
|
||||
|
||||
const editingResponseID = ref<string | null>(null)
|
||||
|
||||
const editingProperties = ref<EditingProperties>({
|
||||
|
|
@ -720,6 +751,7 @@ const showModalEditRequest = ref(false)
|
|||
const showModalEditResponse = ref(false)
|
||||
const showModalImportExport = ref(false)
|
||||
const showModalEditProperties = ref(false)
|
||||
const showModalDocumentation = ref(false)
|
||||
const showConfirmModal = ref(false)
|
||||
const showTeamModalAdd = ref(false)
|
||||
|
||||
|
|
@ -799,6 +831,12 @@ const displayTeamModalAdd = (show: boolean) => {
|
|||
teamListAdapter.fetchList()
|
||||
}
|
||||
|
||||
const displayModalDocumentation = (show: boolean) => {
|
||||
showModalDocumentation.value = show
|
||||
|
||||
if (!show) resetSelectedData()
|
||||
}
|
||||
|
||||
const addNewRootCollection = async (name: string) => {
|
||||
if (collectionsType.value.type === "my-collections") {
|
||||
modalLoadingState.value = true
|
||||
|
|
@ -818,6 +856,7 @@ const addNewRootCollection = async (name: string) => {
|
|||
authActive: true,
|
||||
},
|
||||
variables: [],
|
||||
description: "",
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -2917,6 +2956,7 @@ const editProperties = async (payload: {
|
|||
collection: HoppCollection | TeamCollection
|
||||
}) => {
|
||||
const { collection, collectionIndex } = payload
|
||||
console.log("collection", collection)
|
||||
|
||||
const collectionId = collection.id ?? collectionIndex.split("/").pop()
|
||||
|
||||
|
|
@ -2986,6 +3026,7 @@ const editProperties = async (payload: {
|
|||
} as HoppRESTAuth,
|
||||
headers: [] as HoppRESTHeaders,
|
||||
variables: [] as HoppCollectionVariable[],
|
||||
description: null as string | null,
|
||||
folders: 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,
|
||||
auth: data.auth,
|
||||
headers: data.headers as HoppRESTHeaders,
|
||||
variables: collectionVariables as HoppCollectionVariable[],
|
||||
...collectionData,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3117,9 +3163,13 @@ const setCollectionProperties = (newCollection: {
|
|||
toast.success(t("collection.properties_updated"))
|
||||
} else if (hasTeamWriteAccess.value && collectionId) {
|
||||
const data = {
|
||||
auth: collection.auth,
|
||||
headers: collection.headers,
|
||||
variables: collection.variables,
|
||||
auth: collection.auth ?? {
|
||||
authType: "inherit",
|
||||
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
|
||||
|
|
@ -3130,7 +3180,7 @@ const setCollectionProperties = (newCollection: {
|
|||
}
|
||||
|
||||
pipe(
|
||||
updateTeamCollection(collectionId, JSON.stringify(data), undefined),
|
||||
updateTeamCollection(collectionId, data, undefined),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
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) => {
|
||||
if (title === `${t("confirm.remove_collection")}`) onRemoveCollection()
|
||||
else if (title === `${t("confirm.remove_request")}`) onRemoveRequest()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) {
|
||||
createPublishedDoc(args: $args) {
|
||||
id
|
||||
title
|
||||
version
|
||||
autoSync
|
||||
url
|
||||
createdOn
|
||||
updatedOn
|
||||
workspaceType
|
||||
workspaceID
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation DeletePublishedDoc($id: ID!) {
|
||||
deletePublishedDoc(id: $id)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) {
|
||||
updatePublishedDoc(id: $id, args: $args) {
|
||||
id
|
||||
title
|
||||
version
|
||||
autoSync
|
||||
url
|
||||
createdOn
|
||||
updatedOn
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
query ExportCollectionToJSON($teamID: ID!, $collectionID: ID!) {
|
||||
exportCollectionToJSON(teamID: $teamID, collectionID: $collectionID)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
query UserPublishedDocsList($skip: Int!, $take: Int!) {
|
||||
userPublishedDocsList(skip: $skip, take: $take) {
|
||||
id
|
||||
title
|
||||
version
|
||||
autoSync
|
||||
url
|
||||
collection {
|
||||
id
|
||||
}
|
||||
createdOn
|
||||
updatedOn
|
||||
}
|
||||
}
|
||||
|
|
@ -20,22 +20,25 @@ import { TeamRequest } from "../teams/TeamRequest"
|
|||
import { GQLError, runGQLQuery } from "./GQLClient"
|
||||
import {
|
||||
ExportAsJsonDocument,
|
||||
ExportCollectionToJsonDocument,
|
||||
GetCollectionChildrenIDsDocument,
|
||||
GetCollectionRequestsDocument,
|
||||
GetCollectionTitleAndDataDocument,
|
||||
} from "./graphql"
|
||||
|
||||
type TeamCollectionJSON = {
|
||||
id: string
|
||||
name: string
|
||||
folders: TeamCollectionJSON[]
|
||||
requests: HoppRESTRequest[]
|
||||
data: string
|
||||
data: string | null
|
||||
}
|
||||
|
||||
type CollectionDataProps = {
|
||||
export type CollectionDataProps = {
|
||||
auth: HoppRESTAuth
|
||||
headers: HoppRESTHeaders
|
||||
variables: HoppCollectionVariable[]
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export const BACKEND_PAGE_SIZE = 10
|
||||
|
|
@ -116,6 +119,7 @@ const parseCollectionData = (
|
|||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
|
@ -149,26 +153,36 @@ const parseCollectionData = (
|
|||
defaultDataProps.variables
|
||||
)
|
||||
|
||||
const description =
|
||||
typeof parsedData?.description === "string"
|
||||
? parsedData.description
|
||||
: defaultDataProps.description
|
||||
|
||||
return {
|
||||
auth,
|
||||
headers,
|
||||
variables,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
// Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection`
|
||||
const teamCollectionJSONToHoppRESTColl = (
|
||||
export const teamCollectionJSONToHoppRESTColl = (
|
||||
coll: TeamCollectionJSON
|
||||
): HoppCollection => {
|
||||
const { auth, headers, variables } = parseCollectionData(coll.data)
|
||||
const { auth, headers, variables, description } = parseCollectionData(
|
||||
coll.data
|
||||
)
|
||||
|
||||
return makeCollection({
|
||||
id: coll.id,
|
||||
name: coll.name,
|
||||
folders: coll.folders.map(teamCollectionJSONToHoppRESTColl),
|
||||
folders: coll.folders?.map(teamCollectionJSONToHoppRESTColl),
|
||||
requests: coll.requests,
|
||||
auth,
|
||||
headers,
|
||||
variables,
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -229,9 +243,10 @@ export const teamCollToHoppRESTColl = (
|
|||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
const { auth, headers, variables } = parseCollectionData(data)
|
||||
const { auth, headers, variables, description } = parseCollectionData(data)
|
||||
|
||||
return makeCollection({
|
||||
id: coll.id,
|
||||
|
|
@ -241,6 +256,7 @@ export const teamCollToHoppRESTColl = (
|
|||
auth: auth ?? { authType: "inherit", authActive: true },
|
||||
headers: headers ?? [],
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -272,3 +288,36 @@ export const getTeamCollectionJSON = async (teamID: string) => {
|
|||
const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl)
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
UpdateTeamCollectionMutation,
|
||||
UpdateTeamCollectionMutationVariables,
|
||||
} from "../graphql"
|
||||
import { CollectionDataProps } from "../helpers"
|
||||
|
||||
type CreateNewRootCollectionError = "team_coll/short_title"
|
||||
|
||||
|
|
@ -135,7 +136,7 @@ export const importJSONToTeam = (collectionJSON: string, teamID: string) =>
|
|||
|
||||
export const updateTeamCollection = (
|
||||
collectionID: string,
|
||||
data?: string,
|
||||
data?: CollectionDataProps,
|
||||
newTitle?: string
|
||||
) =>
|
||||
runMutation<
|
||||
|
|
@ -144,7 +145,7 @@ export const updateTeamCollection = (
|
|||
""
|
||||
>(UpdateTeamCollectionDocument, {
|
||||
collectionID,
|
||||
data,
|
||||
data: JSON.stringify(data),
|
||||
newTitle,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
@ -550,12 +550,43 @@ const getHoppScripts = (
|
|||
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 = (
|
||||
item: Item,
|
||||
importScripts: boolean
|
||||
): HoppRESTRequest => {
|
||||
const { preRequestScript, testScript } = getHoppScripts(item, importScripts)
|
||||
|
||||
return makeRESTRequest({
|
||||
name: item.name,
|
||||
endpoint: getHoppReqURL(item.request.url),
|
||||
|
|
@ -571,6 +602,7 @@ const getHoppRequest = (
|
|||
responses: getHoppResponses(item.responses),
|
||||
preRequestScript,
|
||||
testScript,
|
||||
description: getRequestDescription(item.request.description),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -593,6 +625,7 @@ const getHoppFolder = (
|
|||
auth: getHoppReqAuth(ig.auth),
|
||||
headers: [],
|
||||
variables: getHoppCollVariables(ig),
|
||||
description: getCollectionDescription(ig.description),
|
||||
})
|
||||
|
||||
export const getHoppCollections = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -37,6 +37,7 @@ const defaultRESTCollectionState = {
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
|
@ -53,6 +54,7 @@ const defaultGraphqlCollectionState = {
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
|
@ -362,6 +364,7 @@ const restCollectionDispatchers = defineDispatchers({
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
})
|
||||
|
||||
const newState = state
|
||||
|
|
@ -1022,6 +1025,7 @@ const gqlCollectionDispatchers = defineDispatchers({
|
|||
},
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
})
|
||||
const newState = state
|
||||
const indexPaths = path.split("/").map((x) => parseInt(x))
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export type SettingsDef = {
|
|||
|
||||
EXPERIMENTAL_SCRIPTING_SANDBOX: boolean
|
||||
ENABLE_EXPERIMENTAL_MOCK_SERVERS: boolean
|
||||
ENABLE_EXPERIMENTAL_DOCUMENTATION: boolean
|
||||
}
|
||||
|
||||
let defaultProxyURL = DEFAULT_HOPP_PROXY_URL
|
||||
|
|
@ -148,6 +149,7 @@ export const getDefaultSettings = (): SettingsDef => {
|
|||
|
||||
EXPERIMENTAL_SCRIPTING_SANDBOX: true,
|
||||
ENABLE_EXPERIMENTAL_MOCK_SERVERS: true,
|
||||
ENABLE_EXPERIMENTAL_DOCUMENTATION: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-4">
|
||||
|
||||
<div class="flex flex-col space-y-4 py-4">
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle
|
||||
:on="EXPERIMENTAL_SCRIPTING_SANDBOX"
|
||||
@change="toggleSetting('EXPERIMENTAL_SCRIPTING_SANDBOX')"
|
||||
|
|
@ -172,6 +174,15 @@
|
|||
{{ 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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -365,6 +376,9 @@ const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
|
|||
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
|
||||
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
|
||||
)
|
||||
const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting(
|
||||
"ENABLE_EXPERIMENTAL_DOCUMENTATION"
|
||||
)
|
||||
|
||||
const supportedNamingStyles = [
|
||||
{
|
||||
|
|
|
|||
208
packages/hoppscotch-common/src/pages/view/_id/_version.vue
Normal file
208
packages/hoppscotch-common/src/pages/view/_id/_version.vue
Normal 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>
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
229
packages/hoppscotch-common/src/services/documentation.service.ts
Normal file
229
packages/hoppscotch-common/src/services/documentation.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
|
|||
|
||||
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: "Echo",
|
||||
requests: [
|
||||
{
|
||||
|
|
@ -47,18 +47,20 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
|
|||
},
|
||||
requestVariables: [],
|
||||
responses: {},
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
auth: { authType: "none", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
|
||||
{
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: "Echo",
|
||||
requests: [
|
||||
{
|
||||
|
|
@ -77,6 +79,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
|
|||
auth: { authType: "none", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
folders: [],
|
||||
},
|
||||
]
|
||||
|
|
@ -173,6 +176,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [
|
|||
requestVariables: [],
|
||||
v: RESTReqSchemaVersion,
|
||||
responses: {},
|
||||
description: null,
|
||||
},
|
||||
responseMeta: { duration: 807, statusCode: 200 },
|
||||
star: false,
|
||||
|
|
@ -240,6 +244,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState<HoppRequestDocument> = {
|
|||
body: { contentType: null, body: null },
|
||||
requestVariables: [],
|
||||
responses: {},
|
||||
description: null,
|
||||
_ref_id: "req_ref_id",
|
||||
},
|
||||
isDirty: false,
|
||||
|
|
|
|||
|
|
@ -472,6 +472,7 @@ export class PersistenceService extends Service {
|
|||
|
||||
if (result.success) {
|
||||
const translatedData = result.data.map(translateToNewRESTCollection)
|
||||
|
||||
setRESTCollections(translatedData)
|
||||
} else {
|
||||
console.error(`Failed with `, result.error, data)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ const SettingsDefSchema = z.object({
|
|||
|
||||
EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()),
|
||||
ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()),
|
||||
ENABLE_EXPERIMENTAL_DOCUMENTATION: z.optional(z.boolean()),
|
||||
})
|
||||
|
||||
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)
|
||||
|
|
|
|||
|
|
@ -1234,4 +1234,68 @@ export class TeamCollectionsService extends Service<void> {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export class TestRunnerService extends Service {
|
|||
folders: [],
|
||||
requests: [],
|
||||
variables: [],
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
this.runTestCollection(tab, collection, options)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import V7_VERSION from "./v/7"
|
|||
import V8_VERSION from "./v/8"
|
||||
import V9_VERSION from "./v/9"
|
||||
import V10_VERSION from "./v/10"
|
||||
import V11_VERSION from "./v/11"
|
||||
|
||||
export { CollectionVariable } from "./v/10"
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ const versionedObject = z.object({
|
|||
})
|
||||
|
||||
export const HoppCollection = createVersionedEntity({
|
||||
latestVersion: 10,
|
||||
latestVersion: 11,
|
||||
versionMap: {
|
||||
1: V1_VERSION,
|
||||
2: V2_VERSION,
|
||||
|
|
@ -35,6 +36,7 @@ export const HoppCollection = createVersionedEntity({
|
|||
8: V8_VERSION,
|
||||
9: V9_VERSION,
|
||||
10: V10_VERSION,
|
||||
11: V11_VERSION,
|
||||
},
|
||||
getVersion(data) {
|
||||
const versionCheck = versionedObject.safeParse(data)
|
||||
|
|
@ -54,7 +56,7 @@ export type HoppCollectionVariable = InferredEntity<
|
|||
typeof HoppCollection
|
||||
>["variables"][number]
|
||||
|
||||
export const CollectionSchemaVersion = 10
|
||||
export const CollectionSchemaVersion = 11
|
||||
|
||||
/**
|
||||
* 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 variables = x.variables ?? []
|
||||
|
||||
const description = x.description ?? null
|
||||
|
||||
const obj = makeCollection({
|
||||
name,
|
||||
folders,
|
||||
|
|
@ -91,10 +95,13 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
|
|||
auth,
|
||||
headers,
|
||||
variables,
|
||||
description,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -114,6 +121,8 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
|
|||
const headers = x.headers ?? []
|
||||
const variables = x.variables ?? []
|
||||
|
||||
const description = x.description ?? null
|
||||
|
||||
const obj = makeCollection({
|
||||
name,
|
||||
folders,
|
||||
|
|
@ -121,10 +130,13 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
|
|||
auth,
|
||||
headers,
|
||||
variables,
|
||||
description,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
45
packages/hoppscotch-data/src/collection/v/11.ts
Normal file
45
packages/hoppscotch-data/src/collection/v/11.ts
Normal 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
|
||||
},
|
||||
})
|
||||
|
|
@ -27,6 +27,7 @@ import V15_VERSION from "./v/15/index"
|
|||
import V16_VERSION from "./v/16"
|
||||
import { HoppRESTRequestResponses } from "../rest-request-response"
|
||||
import { generateUniqueRefId } from "../utils/collection"
|
||||
import V17_VERSION from "./v/17"
|
||||
|
||||
export * from "./content-types"
|
||||
|
||||
|
|
@ -77,7 +78,7 @@ const versionedObject = z.object({
|
|||
})
|
||||
|
||||
export const HoppRESTRequest = createVersionedEntity({
|
||||
latestVersion: 16,
|
||||
latestVersion: 17,
|
||||
versionMap: {
|
||||
0: V0_VERSION,
|
||||
1: V1_VERSION,
|
||||
|
|
@ -96,6 +97,7 @@ export const HoppRESTRequest = createVersionedEntity({
|
|||
14: V14_VERSION,
|
||||
15: V15_VERSION,
|
||||
16: V16_VERSION,
|
||||
17: V17_VERSION,
|
||||
},
|
||||
getVersion(data) {
|
||||
// For V1 onwards we have the v string storing the number
|
||||
|
|
@ -137,9 +139,10 @@ const HoppRESTRequestEq = Eq.struct<HoppRESTRequest>({
|
|||
),
|
||||
responses: lodashIsEqualEq,
|
||||
_ref_id: undefinedEq(S.Eq),
|
||||
description: lodashIsEqualEq,
|
||||
})
|
||||
|
||||
export const RESTReqSchemaVersion = "16"
|
||||
export const RESTReqSchemaVersion = "17"
|
||||
|
||||
export type HoppRESTParam = HoppRESTRequest["params"][number]
|
||||
export type HoppRESTHeader = HoppRESTRequest["headers"][number]
|
||||
|
|
@ -227,6 +230,10 @@ export function safelyExtractRESTRequest(
|
|||
req.responses = result.data
|
||||
}
|
||||
}
|
||||
|
||||
if ("description" in x && typeof x.description === "string") {
|
||||
req.description = x.description
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
|
|
@ -243,6 +250,7 @@ export function makeRESTRequest(
|
|||
}
|
||||
|
||||
export function getDefaultRESTRequest(): HoppRESTRequest {
|
||||
const ref_id = generateUniqueRefId("req")
|
||||
return {
|
||||
v: RESTReqSchemaVersion,
|
||||
endpoint: "https://echo.hoppscotch.io",
|
||||
|
|
@ -262,7 +270,8 @@ export function getDefaultRESTRequest(): HoppRESTRequest {
|
|||
},
|
||||
requestVariables: [],
|
||||
responses: {},
|
||||
_ref_id: generateUniqueRefId("req"),
|
||||
_ref_id: ref_id,
|
||||
description: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
22
packages/hoppscotch-data/src/rest/v/17.ts
Normal file
22
packages/hoppscotch-data/src/rest/v/17.ts
Normal 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
|
||||
|
|
@ -136,11 +136,12 @@ function exportedCollectionToHoppCollection(
|
|||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: restCollection.id,
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: restCollection.name,
|
||||
folders: restCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -165,6 +166,7 @@ function exportedCollectionToHoppCollection(
|
|||
testScript,
|
||||
requestVariables,
|
||||
responses,
|
||||
description,
|
||||
} = request
|
||||
|
||||
const resolvedParams = addDescriptionField(params)
|
||||
|
|
@ -184,11 +186,13 @@ function exportedCollectionToHoppCollection(
|
|||
preRequestScript,
|
||||
testScript,
|
||||
responses,
|
||||
description,
|
||||
}
|
||||
}),
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
}
|
||||
} else {
|
||||
const gqlCollection = collection as ExportedUserCollectionGQL
|
||||
|
|
@ -200,11 +204,12 @@ function exportedCollectionToHoppCollection(
|
|||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: gqlCollection.id,
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: gqlCollection.name,
|
||||
folders: gqlCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -233,6 +238,7 @@ function exportedCollectionToHoppCollection(
|
|||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -382,6 +388,7 @@ function setupUserCollectionCreatedSubscription() {
|
|||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
runDispatchWithOutSyncing(() => {
|
||||
|
|
@ -390,19 +397,21 @@ function setupUserCollectionCreatedSubscription() {
|
|||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
: addRESTCollection({
|
||||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
|
||||
const localIndex = collectionStore.value.state.length - 1
|
||||
|
|
@ -605,13 +614,14 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
)
|
||||
|
||||
// Incoming data transformed to the respective internal representations
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data != "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
const folders = transformDuplicatedCollections(childCollectionsJSONStr)
|
||||
|
|
@ -626,10 +636,11 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
name,
|
||||
folders,
|
||||
requests,
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
|
||||
// only folders will have parent collection id
|
||||
|
|
@ -1093,13 +1104,14 @@ function transformDuplicatedCollections(
|
|||
requests: userRequests,
|
||||
title: name,
|
||||
}) => {
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data !== "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
const folders = transformDuplicatedCollections(childCollectionsJSONStr)
|
||||
|
|
@ -1111,10 +1123,11 @@ function transformDuplicatedCollections(
|
|||
name,
|
||||
folders,
|
||||
requests,
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const recursivelySyncCollections = async (
|
|||
},
|
||||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
description: collection.description ?? null,
|
||||
_ref_id: collection._ref_id,
|
||||
}
|
||||
const res = await createRESTRootUserCollection(
|
||||
|
|
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
variables: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = parentCollectionID
|
||||
|
|
@ -88,6 +90,7 @@ const recursivelySyncCollections = async (
|
|||
collection.auth = returnedData.auth
|
||||
collection.headers = returnedData.headers
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
|
||||
} else {
|
||||
parentCollectionID = undefined
|
||||
|
|
@ -102,6 +105,7 @@ const recursivelySyncCollections = async (
|
|||
},
|
||||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
description: collection.description ?? null,
|
||||
_ref_id: collection._ref_id,
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +128,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = childCollectionId
|
||||
|
|
@ -132,6 +137,7 @@ const recursivelySyncCollections = async (
|
|||
collection.headers = returnedData.headers
|
||||
parentCollectionID = childCollectionId
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
childCollectionId,
|
||||
|
|
@ -177,6 +183,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
|
|||
},
|
||||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
description: collection.description ?? null,
|
||||
_ref_id: collection._ref_id,
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +261,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: collection.headers,
|
||||
variables: collection.variables,
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description,
|
||||
}
|
||||
|
||||
if (collectionID) {
|
||||
|
|
@ -335,6 +343,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: folder.headers,
|
||||
variables: folder.variables,
|
||||
_ref_id: folder._ref_id,
|
||||
description: folder.description,
|
||||
}
|
||||
|
||||
if (folderID) {
|
||||
|
|
|
|||
|
|
@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection(
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: restCollection.id,
|
||||
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: restCollection.name,
|
||||
folders: restCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -170,6 +171,8 @@ function exportedCollectionToHoppCollection(
|
|||
testScript,
|
||||
requestVariables,
|
||||
responses,
|
||||
description,
|
||||
_ref_id,
|
||||
} = request
|
||||
|
||||
const resolvedParams = addDescriptionField(params)
|
||||
|
|
@ -189,11 +192,14 @@ function exportedCollectionToHoppCollection(
|
|||
preRequestScript,
|
||||
testScript,
|
||||
responses,
|
||||
description: description ?? null,
|
||||
_ref_id: _ref_id ?? generateUniqueRefId("req"),
|
||||
}
|
||||
}),
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
}
|
||||
} else {
|
||||
const gqlCollection = collection as ExportedUserCollectionGQL
|
||||
|
|
@ -206,12 +212,13 @@ function exportedCollectionToHoppCollection(
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: gqlCollection.id,
|
||||
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: gqlCollection.name,
|
||||
folders: gqlCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -240,6 +247,7 @@ function exportedCollectionToHoppCollection(
|
|||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -398,21 +406,23 @@ function setupUserCollectionCreatedSubscription() {
|
|||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id: data._ref_id,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
: addRESTCollection({
|
||||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id: data._ref_id,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
|
||||
const localIndex = collectionStore.value.state.length - 1
|
||||
|
|
@ -615,13 +625,14 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
)
|
||||
|
||||
// Incoming data transformed to the respective internal representations
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data != "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
// Duplicated collection will have a unique ref id
|
||||
const _ref_id = generateUniqueRefId("coll")
|
||||
|
|
@ -638,11 +649,12 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
name,
|
||||
folders,
|
||||
requests,
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
|
||||
// only folders will have parent collection id
|
||||
|
|
@ -1106,13 +1118,14 @@ function transformDuplicatedCollections(
|
|||
requests: userRequests,
|
||||
title: name,
|
||||
}) => {
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data !== "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
const _ref_id = generateUniqueRefId("coll")
|
||||
|
|
@ -1127,10 +1140,11 @@ function transformDuplicatedCollections(
|
|||
folders,
|
||||
requests,
|
||||
_ref_id,
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
const res = await createRESTRootUserCollection(
|
||||
collection.name,
|
||||
|
|
@ -99,6 +101,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
variables: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = parentCollectionID
|
||||
|
|
@ -106,6 +109,7 @@ const recursivelySyncCollections = async (
|
|||
collection.auth = returnedData.auth
|
||||
collection.headers = returnedData.headers
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
|
||||
} else {
|
||||
parentCollectionID = undefined
|
||||
|
|
@ -120,6 +124,7 @@ const recursivelySyncCollections = async (
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
const res = await createRESTChildUserCollection(
|
||||
|
|
@ -141,6 +146,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
variables: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = childCollectionId
|
||||
|
|
@ -149,6 +155,7 @@ const recursivelySyncCollections = async (
|
|||
collection.headers = returnedData.headers
|
||||
parentCollectionID = childCollectionId
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
childCollectionId,
|
||||
|
|
@ -260,6 +267,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: collection.headers,
|
||||
variables: collection.variables,
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
if (collectionID) {
|
||||
|
|
@ -342,6 +350,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: folder.headers,
|
||||
variables: folder.variables,
|
||||
_ref_id: folder._ref_id,
|
||||
description: folder.description ?? null,
|
||||
}
|
||||
if (folderID) {
|
||||
updateUserCollection(folderID, folderName, JSON.stringify(data))
|
||||
|
|
|
|||
|
|
@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection(
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: restCollection.id,
|
||||
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: restCollection.name,
|
||||
folders: restCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -170,6 +171,7 @@ function exportedCollectionToHoppCollection(
|
|||
testScript,
|
||||
requestVariables,
|
||||
responses,
|
||||
description,
|
||||
_ref_id,
|
||||
} = request
|
||||
|
||||
|
|
@ -190,9 +192,11 @@ function exportedCollectionToHoppCollection(
|
|||
preRequestScript,
|
||||
testScript,
|
||||
responses,
|
||||
description: description ?? null,
|
||||
_ref_id: _ref_id ?? generateUniqueRefId("req"),
|
||||
}
|
||||
}),
|
||||
description: data.description ?? null,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
|
|
@ -208,12 +212,13 @@ function exportedCollectionToHoppCollection(
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
return {
|
||||
id: gqlCollection.id,
|
||||
_ref_id: data._ref_id ?? generateUniqueRefId("coll"),
|
||||
v: 10,
|
||||
v: 11,
|
||||
name: gqlCollection.name,
|
||||
folders: gqlCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
|
|
@ -242,6 +247,7 @@ function exportedCollectionToHoppCollection(
|
|||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -392,6 +398,7 @@ function setupUserCollectionCreatedSubscription() {
|
|||
headers: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
runDispatchWithOutSyncing(() => {
|
||||
|
|
@ -400,21 +407,23 @@ function setupUserCollectionCreatedSubscription() {
|
|||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id: data._ref_id,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
: addRESTCollection({
|
||||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id: data._ref_id,
|
||||
auth: data.auth,
|
||||
headers: addDescriptionField(data.headers),
|
||||
variables: data.variables ?? [],
|
||||
description: data.description ?? null,
|
||||
})
|
||||
|
||||
const localIndex = collectionStore.value.state.length - 1
|
||||
|
|
@ -617,13 +626,14 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
)
|
||||
|
||||
// Incoming data transformed to the respective internal representations
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data != "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
// Duplicated collection will have a unique ref id
|
||||
const _ref_id = generateUniqueRefId("coll")
|
||||
|
|
@ -640,11 +650,12 @@ function setupUserCollectionDuplicatedSubscription() {
|
|||
name,
|
||||
folders,
|
||||
requests,
|
||||
v: 10,
|
||||
v: 11,
|
||||
_ref_id,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
|
||||
// only folders will have parent collection id
|
||||
|
|
@ -1108,13 +1119,14 @@ function transformDuplicatedCollections(
|
|||
requests: userRequests,
|
||||
title: name,
|
||||
}) => {
|
||||
const { auth, headers, variables } =
|
||||
const { auth, headers, variables, description } =
|
||||
data && data !== "null"
|
||||
? JSON.parse(data)
|
||||
: {
|
||||
auth: { authType: "inherit", authActive: true },
|
||||
headers: [],
|
||||
variables: [],
|
||||
description: null,
|
||||
}
|
||||
|
||||
const _ref_id = generateUniqueRefId("coll")
|
||||
|
|
@ -1129,10 +1141,11 @@ function transformDuplicatedCollections(
|
|||
folders,
|
||||
requests,
|
||||
_ref_id,
|
||||
v: 10,
|
||||
v: 11,
|
||||
auth,
|
||||
headers: addDescriptionField(headers),
|
||||
variables: variables ?? [],
|
||||
description: description ?? null,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -81,6 +82,7 @@ const recursivelySyncCollections = async (
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
const res = await createRESTRootUserCollection(
|
||||
collection.name,
|
||||
|
|
@ -99,6 +101,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
variables: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = parentCollectionID
|
||||
|
|
@ -106,6 +109,7 @@ const recursivelySyncCollections = async (
|
|||
collection.auth = returnedData.auth
|
||||
collection.headers = returnedData.headers
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
parentCollectionID,
|
||||
`${collectionPath}`
|
||||
|
|
@ -123,6 +127,7 @@ const recursivelySyncCollections = async (
|
|||
headers: collection.headers ?? [],
|
||||
variables: collection.variables ?? [],
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
const res = await createRESTChildUserCollection(
|
||||
|
|
@ -144,6 +149,7 @@ const recursivelySyncCollections = async (
|
|||
headers: [],
|
||||
variables: [],
|
||||
_ref_id: generateUniqueRefId("coll"),
|
||||
description: null,
|
||||
}
|
||||
|
||||
collection.id = childCollectionId
|
||||
|
|
@ -152,6 +158,7 @@ const recursivelySyncCollections = async (
|
|||
collection.headers = returnedData.headers
|
||||
parentCollectionID = childCollectionId
|
||||
collection.variables = returnedData.variables
|
||||
collection.description = returnedData.description ?? null
|
||||
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
childCollectionId,
|
||||
|
|
@ -263,6 +270,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: collection.headers,
|
||||
variables: collection.variables,
|
||||
_ref_id: collection._ref_id,
|
||||
description: collection.description ?? null,
|
||||
}
|
||||
|
||||
if (collectionID) {
|
||||
|
|
@ -346,6 +354,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
|
|||
headers: folder.headers,
|
||||
variables: folder.variables,
|
||||
_ref_id: folder._ref_id,
|
||||
description: folder.description,
|
||||
}
|
||||
if (folderID) {
|
||||
updateUserCollection(folderID, folderName, JSON.stringify(data))
|
||||
|
|
|
|||
|
|
@ -596,6 +596,9 @@ importers:
|
|||
dioc:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2(vue@3.5.22(typescript@5.9.3))
|
||||
dompurify:
|
||||
specifier: 3.3.0
|
||||
version: 3.3.0
|
||||
esprima:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
|
|
@ -620,6 +623,12 @@ importers:
|
|||
hawk:
|
||||
specifier: 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:
|
||||
specifier: 3.6.0
|
||||
version: 3.6.0(openapi-types@12.1.3)
|
||||
|
|
@ -8311,6 +8320,9 @@ packages:
|
|||
dompurify@3.2.7:
|
||||
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:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
|
||||
|
|
@ -9415,6 +9427,13 @@ packages:
|
|||
header-case@2.0.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
|
@ -22214,6 +22233,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.3.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@2.8.0:
|
||||
dependencies:
|
||||
dom-serializer: 1.4.1
|
||||
|
|
@ -23724,6 +23747,10 @@ snapshots:
|
|||
capital-case: 1.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
highlightjs-curl@1.3.0: {}
|
||||
|
||||
hono@4.10.3: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue