feat: api documentation versioning (#5676)
Co-authored-by: nivedin <nivedinp@gmail.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
faf2bfc8eb
commit
803e4633a2
37 changed files with 3446 additions and 485 deletions
|
|
@ -0,0 +1,31 @@
|
|||
-- Step 1: Add slug column as nullable first
|
||||
ALTER TABLE "PublishedDocs" ADD COLUMN "slug" TEXT;
|
||||
|
||||
-- Step 2: For backward compatibility, set slug = id for existing records
|
||||
UPDATE "PublishedDocs" SET "slug" = "id" WHERE "slug" IS NULL;
|
||||
|
||||
-- Step 3: Handle duplicates - for multiple published docs with same collection and version
|
||||
-- Keep the latest one (most recent), delete all older ones
|
||||
-- delete old duplicates are safe, as multiple published docs with same collection and version is not expected behavior till v2025.11.x
|
||||
WITH ranked_docs AS (
|
||||
SELECT
|
||||
id,
|
||||
"collectionID",
|
||||
version,
|
||||
"createdOn",
|
||||
ROW_NUMBER() OVER (PARTITION BY "collectionID", version ORDER BY "createdOn" DESC) as rn
|
||||
FROM "PublishedDocs"
|
||||
)
|
||||
DELETE FROM "PublishedDocs"
|
||||
WHERE id IN (
|
||||
SELECT id FROM ranked_docs WHERE rn > 1
|
||||
);
|
||||
|
||||
-- Step 4: Now make slug NOT NULL
|
||||
ALTER TABLE "PublishedDocs" ALTER COLUMN "slug" SET NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PublishedDocs_collectionID_idx" ON "PublishedDocs"("collectionID");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PublishedDocs_slug_version_key" ON "PublishedDocs"("slug", "version");
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "PublishedDocs" ADD COLUMN "environmentID" TEXT,
|
||||
ADD COLUMN "environmentName" TEXT,
|
||||
ADD COLUMN "environmentVariables" JSONB;
|
||||
|
|
@ -297,18 +297,25 @@ model MockServerActivity {
|
|||
}
|
||||
|
||||
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)
|
||||
id String @id @default(cuid())
|
||||
slug String
|
||||
title String
|
||||
collectionID String
|
||||
creatorUid String
|
||||
version String
|
||||
autoSync Boolean
|
||||
documentTree Json? // Optional if autoSync is true
|
||||
workspaceType WorkspaceType
|
||||
workspaceID String
|
||||
environmentID String?
|
||||
environmentName String?
|
||||
environmentVariables Json?
|
||||
metadata Json?
|
||||
createdOn DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@unique([slug, version])
|
||||
@@index([collectionID])
|
||||
}
|
||||
|
||||
enum WorkspaceType {
|
||||
|
|
|
|||
|
|
@ -975,6 +975,13 @@ export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed';
|
|||
*/
|
||||
export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_failed';
|
||||
|
||||
/**
|
||||
* Published Docs invalid environment
|
||||
* (PublishedDocsService)
|
||||
*/
|
||||
export const PUBLISHED_DOCS_INVALID_ENVIRONMENT =
|
||||
'published_docs/invalid_environment';
|
||||
|
||||
/**
|
||||
* Published Docs not found
|
||||
* (PublishedDocsService)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ export class CreatePublishedDocsArgs {
|
|||
description: 'Metadata associated with the published document',
|
||||
})
|
||||
metadata: string;
|
||||
|
||||
@Field({
|
||||
name: 'environmentID',
|
||||
description:
|
||||
'ID of the environment to associate with the published document',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
environmentID?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
|
@ -60,6 +69,7 @@ export class UpdatePublishedDocsArgs {
|
|||
description: 'Title of the published document',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@Field({
|
||||
|
|
@ -80,6 +90,7 @@ export class UpdatePublishedDocsArgs {
|
|||
'Whether the published document should auto-sync with the source',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
autoSync?: boolean;
|
||||
|
||||
@Field({
|
||||
|
|
@ -87,5 +98,15 @@ export class UpdatePublishedDocsArgs {
|
|||
description: 'Metadata associated with the published document',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: string;
|
||||
|
||||
@Field({
|
||||
name: 'environmentID',
|
||||
description:
|
||||
'ID of the environment to associate with the published document. Pass null to remove the environment.',
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
environmentID?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ import {
|
|||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} 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';
|
||||
|
|
@ -21,12 +19,12 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua
|
|||
export class PublishedDocsController {
|
||||
constructor(private readonly publishedDocsService: PublishedDocsService) {}
|
||||
|
||||
@Get(':docId')
|
||||
@Get(':slug')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Get published documentation',
|
||||
summary: 'Get latest published documentation by slug',
|
||||
description:
|
||||
'Returns published collection documentation in API-doc JSON format for unauthenticated users',
|
||||
'Returns the latest version of published collection documentation by slug for unauthenticated users.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
|
|
@ -37,13 +35,42 @@ export class PublishedDocsController {
|
|||
status: 404,
|
||||
description: 'Published documentation not found',
|
||||
})
|
||||
async getPublishedDocs(
|
||||
@Param('docId') docId: string,
|
||||
@Query() query: GetPublishedDocsQueryDto,
|
||||
async getPublishedDocsBySlugLatest(@Param('slug') slug: string) {
|
||||
const result = await this.publishedDocsService.getPublishedDocBySlugPublic(
|
||||
slug,
|
||||
null,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND });
|
||||
}
|
||||
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Get(':slug/:version')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Get published documentation by slug and version',
|
||||
description:
|
||||
'Returns published collection documentation by slug and version for unauthenticated users.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Successfully retrieved published documentation',
|
||||
type: () => PublishedDocs,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Published documentation not found',
|
||||
})
|
||||
async getPublishedDocsBySlug(
|
||||
@Param('slug') slug: string,
|
||||
@Param('version') version: string,
|
||||
) {
|
||||
const result = await this.publishedDocsService.getPublishedDocByIDPublic(
|
||||
docId,
|
||||
query,
|
||||
const result = await this.publishedDocsService.getPublishedDocBySlugPublic(
|
||||
slug,
|
||||
version,
|
||||
);
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,5 +1,69 @@
|
|||
import { ObjectType, Field, ID } from '@nestjs/graphql';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
|
||||
@ObjectType()
|
||||
export class PublishedDocsVersion {
|
||||
@Field(() => ID, {
|
||||
description: 'ID of the published document version',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'ID of the published document version',
|
||||
example: 'doc_12345',
|
||||
})
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Slug of the published document',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Slug of the published document',
|
||||
example: 'abc-123-uuid',
|
||||
})
|
||||
@Expose()
|
||||
slug: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Version string',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Version string',
|
||||
example: '1.0.0',
|
||||
})
|
||||
@Expose()
|
||||
version: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Title of the API documentation',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Title of the API documentation',
|
||||
example: 'API Documentation v1.0',
|
||||
})
|
||||
@Expose()
|
||||
title: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Indicates if the documentation is set to auto-sync',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Indicates if the documentation is set to auto-sync',
|
||||
example: true,
|
||||
})
|
||||
@Expose()
|
||||
autoSync: boolean;
|
||||
|
||||
@Field(() => String, {
|
||||
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/v1.0',
|
||||
})
|
||||
@Expose()
|
||||
url: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PublishedDocs {
|
||||
|
|
@ -10,13 +74,27 @@ export class PublishedDocs {
|
|||
description: 'ID of the published API documentation',
|
||||
example: 'doc_12345',
|
||||
})
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Field(() => ID, {
|
||||
description:
|
||||
'Slug of the published API documentation (unique with version)',
|
||||
})
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Slug of the published API documentation (unique with version)',
|
||||
example: 'my-api-docs',
|
||||
})
|
||||
@Expose()
|
||||
slug: string;
|
||||
|
||||
@Field({ description: 'Title of the published API documentation' })
|
||||
@ApiProperty({
|
||||
description: 'Title of the published API documentation',
|
||||
example: 'My API Documentation',
|
||||
})
|
||||
@Expose()
|
||||
title: string;
|
||||
|
||||
@Field({
|
||||
|
|
@ -26,6 +104,7 @@ export class PublishedDocs {
|
|||
description: 'URL where the published API documentation can be accessed',
|
||||
example: 'https://docs.example.com/api',
|
||||
})
|
||||
@Expose()
|
||||
url: string;
|
||||
|
||||
@Field({ description: 'Version of the published API documentation' })
|
||||
|
|
@ -33,6 +112,7 @@ export class PublishedDocs {
|
|||
description: 'Version of the published API documentation',
|
||||
example: '1.0.0',
|
||||
})
|
||||
@Expose()
|
||||
version: string;
|
||||
|
||||
@Field({ description: 'Indicates if the documentation is set to auto-sync' })
|
||||
|
|
@ -40,6 +120,7 @@ export class PublishedDocs {
|
|||
description: 'Indicates if the documentation is set to auto-sync',
|
||||
example: true,
|
||||
})
|
||||
@Expose()
|
||||
autoSync: boolean;
|
||||
|
||||
@Field({
|
||||
|
|
@ -50,6 +131,7 @@ export class PublishedDocs {
|
|||
example:
|
||||
'{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}',
|
||||
})
|
||||
@Expose()
|
||||
documentTree: string;
|
||||
|
||||
@Field({
|
||||
|
|
@ -79,13 +161,42 @@ export class PublishedDocs {
|
|||
description: 'Metadata of the documentation',
|
||||
example: '{"author": "John Doe", "tags": ["api", "rest"]}',
|
||||
})
|
||||
@Expose()
|
||||
metadata: string;
|
||||
|
||||
@Field({
|
||||
description: 'Name of the environment associated with the documentation',
|
||||
nullable: true,
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Name of the environment associated with the documentation',
|
||||
example: 'Production',
|
||||
nullable: true,
|
||||
})
|
||||
@Expose()
|
||||
environmentName?: string;
|
||||
|
||||
@Field({
|
||||
description:
|
||||
'Stringified JSON of the environment variables associated with the documentation',
|
||||
nullable: true,
|
||||
})
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Stringified JSON of the environment variables associated with the documentation',
|
||||
example:
|
||||
'[{"key":"base_url","secret":false,"currentValue":"","initialValue":"http://hoppscotch.com"}]',
|
||||
nullable: true,
|
||||
})
|
||||
@Expose()
|
||||
environmentVariables?: string;
|
||||
|
||||
@Field({ description: 'Timestamp when the documentation was created' })
|
||||
@ApiProperty({
|
||||
description: 'Timestamp when the documentation was created',
|
||||
example: '2024-01-01T00:00:00.000Z',
|
||||
})
|
||||
@Expose()
|
||||
createdOn: Date;
|
||||
|
||||
@Field({ description: 'Timestamp when the documentation was last updated' })
|
||||
|
|
@ -93,7 +204,20 @@ export class PublishedDocs {
|
|||
description: 'Timestamp when the documentation was last updated',
|
||||
example: '2024-01-15T12:30:00.000Z',
|
||||
})
|
||||
@Expose()
|
||||
updatedOn: Date;
|
||||
|
||||
@Field(() => [PublishedDocsVersion], {
|
||||
description: 'All available versions of this published documentation',
|
||||
nullable: true,
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'All available versions of this published documentation',
|
||||
type: [PublishedDocsVersion],
|
||||
})
|
||||
@Expose()
|
||||
@Type(() => PublishedDocsVersion)
|
||||
versions?: PublishedDocsVersion[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import {
|
|||
Query,
|
||||
} from '@nestjs/graphql';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { PublishedDocs, PublishedDocsCollection } from './published-docs.model';
|
||||
import {
|
||||
PublishedDocs,
|
||||
PublishedDocsCollection,
|
||||
PublishedDocsVersion,
|
||||
} from './published-docs.model';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import {
|
||||
|
|
@ -60,6 +64,20 @@ export class PublishedDocsResolver {
|
|||
return collection.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => [PublishedDocsVersion], {
|
||||
description: 'Returns all versions of the published document (same slug)',
|
||||
})
|
||||
async versions(
|
||||
@Parent() publishedDocs: PublishedDocs,
|
||||
): Promise<PublishedDocsVersion[]> {
|
||||
const versions = await this.publishedDocsService.getPublishedDocsVersions(
|
||||
publishedDocs.slug,
|
||||
);
|
||||
|
||||
if (E.isLeft(versions)) throwErr(versions.left);
|
||||
return versions.right;
|
||||
}
|
||||
|
||||
// Queries
|
||||
|
||||
@Query(() => PublishedDocs, {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
CreatePublishedDocsArgs,
|
||||
UpdatePublishedDocsArgs,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
PUBLISHED_DOCS_CREATION_FAILED,
|
||||
PUBLISHED_DOCS_DELETION_FAILED,
|
||||
PUBLISHED_DOCS_INVALID_COLLECTION,
|
||||
PUBLISHED_DOCS_INVALID_ENVIRONMENT,
|
||||
PUBLISHED_DOCS_NOT_FOUND,
|
||||
PUBLISHED_DOCS_UPDATE_FAILED,
|
||||
TEAM_INVALID_COLL_ID,
|
||||
|
|
@ -19,13 +21,16 @@ import {
|
|||
USER_COLL_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { PublishedDocs } from './published-docs.model';
|
||||
import { PublishedDocs, PublishedDocsVersion } 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 { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaError } from 'src/prisma/prisma-error-codes';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { JsonValue } from '@prisma/client/runtime/client';
|
||||
|
||||
@Injectable()
|
||||
export class PublishedDocsService {
|
||||
|
|
@ -36,18 +41,83 @@ export class PublishedDocsService {
|
|||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get or generate slug for a collection
|
||||
* - For existing published docs with the same collectionID, reuse the slug
|
||||
* - For new collections, generate a new UUID-based slug
|
||||
*/
|
||||
private async getOrGenerateSlug(
|
||||
collectionID: string,
|
||||
workspaceType: WorkspaceType,
|
||||
workspaceID: string,
|
||||
): Promise<string> {
|
||||
// Check if there's already a published doc for this collection
|
||||
const existingDoc = await this.prisma.publishedDocs.findFirst({
|
||||
where: {
|
||||
collectionID,
|
||||
workspaceType,
|
||||
workspaceID,
|
||||
},
|
||||
orderBy: {
|
||||
createdOn: 'asc', // Get the oldest one
|
||||
},
|
||||
});
|
||||
|
||||
// If exists, reuse its slug
|
||||
if (existingDoc) {
|
||||
return existingDoc.slug;
|
||||
}
|
||||
|
||||
// Otherwise, generate a new slug using crypto.randomUUID()
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast database PublishedDocs to GraphQL PublishedDocs
|
||||
*/
|
||||
private cast(doc: DbPublishedDocs): PublishedDocs {
|
||||
private cast(
|
||||
doc: DbPublishedDocs,
|
||||
versions: PublishedDocsVersion[] = [],
|
||||
): PublishedDocs {
|
||||
return {
|
||||
...doc,
|
||||
versions,
|
||||
documentTree: JSON.stringify(doc.documentTree),
|
||||
metadata: JSON.stringify(doc.metadata),
|
||||
url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.id}/${doc.version}`,
|
||||
environmentName: doc.environmentName ?? null,
|
||||
environmentVariables: doc.environmentVariables
|
||||
? JSON.stringify(doc.environmentVariables)
|
||||
: null,
|
||||
url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.slug}/${doc.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch environment by ID based on workspace type
|
||||
* Returns the environment name and variables, or an error if not found
|
||||
*/
|
||||
private async fetchEnvironment(
|
||||
environmentID: string,
|
||||
workspaceType: WorkspaceType,
|
||||
workspaceID: string,
|
||||
): Promise<E.Either<string, { name: string; variables: JsonValue } | null>> {
|
||||
if (workspaceType === WorkspaceType.TEAM) {
|
||||
const env = await this.prisma.teamEnvironment.findFirst({
|
||||
where: { id: environmentID, teamID: workspaceID },
|
||||
});
|
||||
if (!env) return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT);
|
||||
return E.right({ name: env.name, variables: env.variables });
|
||||
} else if (workspaceType === WorkspaceType.USER) {
|
||||
const env = await this.prisma.userEnvironment.findFirst({
|
||||
where: { id: environmentID, userUid: workspaceID },
|
||||
});
|
||||
if (!env) return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT);
|
||||
return E.right({ name: env.name ?? '', variables: env.variables });
|
||||
}
|
||||
|
||||
return E.left(PUBLISHED_DOCS_INVALID_ENVIRONMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to a team with specific roles
|
||||
*/
|
||||
|
|
@ -195,6 +265,21 @@ export class PublishedDocsService {
|
|||
return E.left(PUBLISHED_DOCS_INVALID_COLLECTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* (Field resolver)
|
||||
* Get all versions of a published document by slug
|
||||
*/
|
||||
async getPublishedDocsVersions(slug: string) {
|
||||
const allVersions = await this.prisma.publishedDocs.findMany({
|
||||
where: { slug },
|
||||
orderBy: [{ autoSync: 'desc' }, { createdOn: 'desc' }],
|
||||
});
|
||||
|
||||
if (allVersions.length === 0) return E.left(PUBLISHED_DOCS_NOT_FOUND);
|
||||
|
||||
return E.right(allVersions.map((doc) => this.cast(doc)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a published document by ID
|
||||
*/
|
||||
|
|
@ -215,19 +300,29 @@ export class PublishedDocsService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Get a published document by slug and version for public access (unauthenticated)
|
||||
* @param slug - The slug of the published document
|
||||
* @param version - The version of the published document
|
||||
*/
|
||||
async getPublishedDocByIDPublic(
|
||||
id: string,
|
||||
query: GetPublishedDocsQueryDto,
|
||||
async getPublishedDocBySlugPublic(
|
||||
slug: string,
|
||||
version: string | null,
|
||||
): Promise<E.Either<string, PublishedDocs>> {
|
||||
const allVersions = await this.getPublishedDocsVersions(slug);
|
||||
if (E.isLeft(allVersions)) return E.left(allVersions.left);
|
||||
|
||||
const publishedDocs = await this.prisma.publishedDocs.findUnique({
|
||||
where: { id },
|
||||
where: {
|
||||
slug_version: {
|
||||
slug,
|
||||
version: version ? version : allVersions.right[0].version, // If version is not specified, get the latest version
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
|
||||
|
||||
let docToReturn = publishedDocs;
|
||||
|
||||
// if autoSync is enabled, fetch from the collection directly
|
||||
if (publishedDocs.autoSync) {
|
||||
const collectionResult =
|
||||
|
|
@ -235,12 +330,10 @@ export class PublishedDocsService {
|
|||
? 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)) {
|
||||
|
|
@ -258,15 +351,44 @@ export class PublishedDocsService {
|
|||
return E.left(collectionResult.left);
|
||||
}
|
||||
|
||||
return E.right(
|
||||
this.cast({
|
||||
...publishedDocs,
|
||||
documentTree: JSON.parse(JSON.stringify(collectionResult.right)),
|
||||
}),
|
||||
);
|
||||
// Re-fetch environment if environmentID is set
|
||||
let environmentName = publishedDocs.environmentName;
|
||||
let environmentVariables = publishedDocs.environmentVariables;
|
||||
|
||||
if (publishedDocs.environmentID) {
|
||||
const workspaceID =
|
||||
publishedDocs.workspaceType === WorkspaceType.USER
|
||||
? publishedDocs.creatorUid
|
||||
: publishedDocs.workspaceID;
|
||||
|
||||
const envResult = await this.fetchEnvironment(
|
||||
publishedDocs.environmentID,
|
||||
publishedDocs.workspaceType as WorkspaceType,
|
||||
workspaceID,
|
||||
);
|
||||
if (E.isLeft(envResult)) return E.left(envResult.left);
|
||||
|
||||
if (E.isRight(envResult) && envResult.right) {
|
||||
environmentName = envResult.right.name;
|
||||
environmentVariables = envResult.right.variables;
|
||||
}
|
||||
}
|
||||
|
||||
docToReturn = {
|
||||
...publishedDocs,
|
||||
documentTree: collectionResult.right as unknown as JsonValue,
|
||||
environmentName,
|
||||
environmentVariables,
|
||||
};
|
||||
}
|
||||
|
||||
return E.right(this.cast(publishedDocs));
|
||||
return E.right(
|
||||
plainToInstance(
|
||||
PublishedDocs,
|
||||
this.cast(docToReturn, allVersions.right),
|
||||
{ excludeExtraneousValues: true, enableCircularCheck: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -281,7 +403,7 @@ export class PublishedDocsService {
|
|||
|
||||
if (docsToDelete.length > 0) {
|
||||
const idsToDelete = docsToDelete.map((doc) => doc.id);
|
||||
this.prisma.publishedDocs.deleteMany({
|
||||
await this.prisma.publishedDocs.deleteMany({
|
||||
where: { id: { in: idsToDelete } },
|
||||
});
|
||||
}
|
||||
|
|
@ -383,7 +505,11 @@ export class PublishedDocsService {
|
|||
* @param args - Arguments for creating the published document
|
||||
* @param user - The user creating the published document
|
||||
*/
|
||||
async createPublishedDoc(args: CreatePublishedDocsArgs, user: User) {
|
||||
async createPublishedDoc(
|
||||
args: CreatePublishedDocsArgs,
|
||||
user: User,
|
||||
retryCount: number = 0,
|
||||
): Promise<E.Either<string, PublishedDocs>> {
|
||||
try {
|
||||
// Validate workspace type and ID
|
||||
const workspaceValidation = await this.validateWorkspace(user, {
|
||||
|
|
@ -408,25 +534,87 @@ export class PublishedDocsService {
|
|||
const metadata = stringToJson(args.metadata);
|
||||
if (E.isLeft(metadata)) return E.left(metadata.left);
|
||||
|
||||
// Create published document
|
||||
// Get or generate slug for this collection
|
||||
const workspaceID =
|
||||
args.workspaceType === WorkspaceType.TEAM ? args.workspaceID : user.uid;
|
||||
|
||||
// Get or generate slug
|
||||
const slug = await this.getOrGenerateSlug(
|
||||
args.collectionID,
|
||||
args.workspaceType,
|
||||
workspaceID,
|
||||
);
|
||||
|
||||
let documentTree: CollectionFolder | null = null;
|
||||
// If autoSync is disabled, fetch the latest collection data for snapshot
|
||||
if (!args.autoSync) {
|
||||
const collectionResult =
|
||||
args.workspaceType === WorkspaceType.USER
|
||||
? await this.userCollectionService.exportUserCollectionToJSONObject(
|
||||
user.uid,
|
||||
args.collectionID,
|
||||
)
|
||||
: await this.teamCollectionService.exportCollectionToJSONObject(
|
||||
args.workspaceID,
|
||||
args.collectionID,
|
||||
);
|
||||
|
||||
if (E.isLeft(collectionResult)) {
|
||||
return E.left(collectionResult.left);
|
||||
}
|
||||
|
||||
documentTree = collectionResult.right;
|
||||
}
|
||||
|
||||
// Fetch environment if environmentID is provided
|
||||
let environmentName: string | null = null;
|
||||
let environmentVariables: JsonValue | null = null;
|
||||
|
||||
if (args.environmentID) {
|
||||
const envResult = await this.fetchEnvironment(
|
||||
args.environmentID,
|
||||
args.workspaceType,
|
||||
workspaceID,
|
||||
);
|
||||
if (E.isLeft(envResult)) return E.left(envResult.left);
|
||||
if (envResult.right) {
|
||||
environmentName = envResult.right.name;
|
||||
environmentVariables = envResult.right.variables;
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to create the published document
|
||||
const newPublishedDoc = await this.prisma.publishedDocs.create({
|
||||
data: {
|
||||
title: args.title,
|
||||
slug: slug,
|
||||
collectionID: args.collectionID,
|
||||
creatorUid: user.uid,
|
||||
version: args.version,
|
||||
autoSync: args.autoSync,
|
||||
workspaceType: args.workspaceType,
|
||||
workspaceID:
|
||||
args.workspaceType === WorkspaceType.TEAM
|
||||
? args.workspaceID
|
||||
: user.uid,
|
||||
workspaceID: workspaceID,
|
||||
documentTree: documentTree as unknown as JsonValue,
|
||||
metadata: metadata.right,
|
||||
environmentID: args.environmentID ?? null,
|
||||
environmentName,
|
||||
environmentVariables,
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(this.cast(newPublishedDoc));
|
||||
} catch (error) {
|
||||
// Check if it's a unique constraint violation on [slug, version]
|
||||
// Allow up to 3 total attempts (initial + 2 retries)
|
||||
const maxRetries = 2;
|
||||
if (
|
||||
error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION &&
|
||||
retryCount < maxRetries
|
||||
) {
|
||||
// Race condition detected: retry with fresh slug generation
|
||||
return this.createPublishedDoc(args, user, retryCount + 1);
|
||||
}
|
||||
|
||||
console.error('Error creating published document:', error);
|
||||
return E.left(PUBLISHED_DOCS_CREATION_FAILED);
|
||||
}
|
||||
|
|
@ -464,6 +652,59 @@ export class PublishedDocsService {
|
|||
if (E.isLeft(metadata)) return E.left(metadata.left);
|
||||
}
|
||||
|
||||
// Determine documentTree based on autoSync value
|
||||
let documentTree: CollectionFolder | null | undefined = undefined; // undefined = no change
|
||||
|
||||
if (args.autoSync === true) {
|
||||
// autoSync enabled → clear documentTree (will be generated dynamically)
|
||||
documentTree = null;
|
||||
} else if (args.autoSync === false && publishedDocs.autoSync === true) {
|
||||
// Switching from autoSync true → false: generate a snapshot of the collection
|
||||
const collectionResult =
|
||||
publishedDocs.workspaceType === WorkspaceType.USER
|
||||
? await this.userCollectionService.exportUserCollectionToJSONObject(
|
||||
publishedDocs.creatorUid,
|
||||
publishedDocs.collectionID,
|
||||
)
|
||||
: await this.teamCollectionService.exportCollectionToJSONObject(
|
||||
publishedDocs.workspaceID,
|
||||
publishedDocs.collectionID,
|
||||
);
|
||||
|
||||
if (E.isLeft(collectionResult)) {
|
||||
return E.left(collectionResult.left);
|
||||
}
|
||||
|
||||
documentTree = collectionResult.right;
|
||||
}
|
||||
|
||||
// Handle environment update if environmentID is provided
|
||||
let environmentName: string | null | undefined = undefined; // undefined = no change
|
||||
let environmentVariables: JsonValue | undefined = undefined;
|
||||
let environmentID: string | null | undefined = undefined;
|
||||
|
||||
if (args.environmentID !== undefined) {
|
||||
if (args.environmentID === null) {
|
||||
// Explicitly removing environment
|
||||
environmentID = null;
|
||||
environmentName = null;
|
||||
environmentVariables = null;
|
||||
} else {
|
||||
// Fetch environment data
|
||||
const envResult = await this.fetchEnvironment(
|
||||
args.environmentID,
|
||||
publishedDocs.workspaceType as WorkspaceType,
|
||||
publishedDocs.workspaceID,
|
||||
);
|
||||
if (E.isLeft(envResult)) return E.left(envResult.left);
|
||||
if (envResult.right) {
|
||||
environmentID = args.environmentID;
|
||||
environmentName = envResult.right.name;
|
||||
environmentVariables = envResult.right.variables;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update published document
|
||||
const updatedPublishedDoc = await this.prisma.publishedDocs.update({
|
||||
where: { id },
|
||||
|
|
@ -471,8 +712,20 @@ export class PublishedDocsService {
|
|||
title: args.title,
|
||||
version: args.version,
|
||||
autoSync: args.autoSync,
|
||||
documentTree:
|
||||
documentTree !== undefined
|
||||
? (documentTree as unknown as JsonValue)
|
||||
: undefined,
|
||||
metadata:
|
||||
metadata && E.isRight(metadata) ? metadata.right : undefined,
|
||||
environmentID:
|
||||
environmentID !== undefined ? environmentID : undefined,
|
||||
environmentName:
|
||||
environmentName !== undefined ? environmentName : undefined,
|
||||
environmentVariables:
|
||||
environmentVariables !== undefined
|
||||
? environmentVariables
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -106,35 +106,32 @@ 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,
|
||||
parentID: collectionID,
|
||||
},
|
||||
orderBy: {
|
||||
orderIndex: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
for (const coll of childrenCollection) {
|
||||
const result = await this.exportCollectionToJSONObject(teamID, coll.id);
|
||||
if (E.isLeft(result)) return E.left(result.left);
|
||||
const childrenCollection = await this.prisma.teamCollection.findMany({
|
||||
where: {
|
||||
teamID,
|
||||
parentID: collectionID,
|
||||
},
|
||||
orderBy: {
|
||||
orderIndex: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
childrenCollectionObjects.push(result.right);
|
||||
}
|
||||
for (const coll of childrenCollection) {
|
||||
const result = await this.exportCollectionToJSONObject(teamID, coll.id);
|
||||
if (E.isLeft(result)) return E.left(result.left);
|
||||
|
||||
childrenCollectionObjects.push(result.right);
|
||||
}
|
||||
|
||||
const requests = await this.prisma.teamRequest.findMany({
|
||||
|
|
|
|||
|
|
@ -853,41 +853,38 @@ 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: {
|
||||
parentID: collectionID,
|
||||
userUid: userUID,
|
||||
},
|
||||
orderBy: {
|
||||
orderIndex: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a list of child collection and request data ready for export
|
||||
for (const coll of childCollectionList) {
|
||||
const result = await this.exportUserCollectionToJSONObject(
|
||||
userUID,
|
||||
coll.id,
|
||||
);
|
||||
if (E.isLeft(result)) return E.left(result.left);
|
||||
// Get all child collections whose parentID === collectionID
|
||||
const childCollectionList = await this.prisma.userCollection.findMany({
|
||||
where: {
|
||||
parentID: collectionID,
|
||||
userUid: userUID,
|
||||
},
|
||||
orderBy: {
|
||||
orderIndex: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
childrenCollectionObjects.push(result.right);
|
||||
}
|
||||
// Create a list of child collection and request data ready for export
|
||||
for (const coll of childCollectionList) {
|
||||
const result = await this.exportUserCollectionToJSONObject(
|
||||
userUID,
|
||||
coll.id,
|
||||
);
|
||||
if (E.isLeft(result)) return E.left(result.left);
|
||||
|
||||
childrenCollectionObjects.push(result.right);
|
||||
}
|
||||
|
||||
// Fetch all child requests that belong to collectionID
|
||||
|
|
|
|||
|
|
@ -533,7 +533,33 @@
|
|||
"update_title": "Update Published Documentation",
|
||||
"url_copied": "URL copied to clipboard!",
|
||||
"view_published": "View Published Docs",
|
||||
"view_title": "View Published Documentation"
|
||||
"view_title": "Published Documentation Snapshot",
|
||||
"versions": "Versions",
|
||||
"create_new_version": "Create New Version",
|
||||
"invalid_version": "Version must only contain alphanumeric characters, dots, and hyphens",
|
||||
"snapshot_description": "This will snapshot the current documentation as this version",
|
||||
"live": "Live",
|
||||
"snapshot": "Snapshot",
|
||||
"version_immutable": "Published versions are read-only snapshots",
|
||||
"view_snapshot": "View Snapshot",
|
||||
"current_version": "CURRENT",
|
||||
"not_found": "Published documentation not found",
|
||||
"no_doc_id": "No document ID provided",
|
||||
"version_label": "v{version}",
|
||||
"unpublish_version": "Unpublish this version",
|
||||
"loading_snapshot": "Loading snapshot...",
|
||||
"retry_snapshot": "Retry",
|
||||
"sensitive_data_warning": "Please make sure no sensitive data is exposed in the published documentation.",
|
||||
"snapshot_preview": "Snapshot Preview",
|
||||
"snapshot_load_error": "Failed to load snapshot preview",
|
||||
"snapshot_empty": "No requests or folders in this snapshot",
|
||||
"snapshot_item_count": "{count} items",
|
||||
"auto_sync_live_notice": "This version auto-syncs with the live collection",
|
||||
"untitled_project": "Untitled Project",
|
||||
"first_publish_hint": "Your documentation will be published as a live version that automatically stays in sync with your collection",
|
||||
"environment": "Environment",
|
||||
"no_environment": "No environment",
|
||||
"environment_description": "Attach an environment to resolve variables in the published documentation"
|
||||
},
|
||||
"request_opened_in_new_tab": "Request opened in new tab!",
|
||||
"response": {
|
||||
|
|
|
|||
11
packages/hoppscotch-common/src/components.d.ts
vendored
11
packages/hoppscotch-common/src/components.d.ts
vendored
|
|
@ -56,11 +56,14 @@ declare module 'vue' {
|
|||
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']
|
||||
CollectionsDocumentationEnvironmentPicker: typeof import('./components/collections/documentation/EnvironmentPicker.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']
|
||||
CollectionsDocumentationPublishDocForm: typeof import('./components/collections/documentation/PublishDocForm.vue')['default']
|
||||
CollectionsDocumentationPublishDocModal: typeof import('./components/collections/documentation/PublishDocModal.vue')['default']
|
||||
CollectionsDocumentationPublishDocSnapshotPreview: typeof import('./components/collections/documentation/PublishDocSnapshotPreview.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']
|
||||
|
|
@ -70,6 +73,7 @@ declare module 'vue' {
|
|||
CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default']
|
||||
CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default']
|
||||
CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default']
|
||||
CollectionsDocumentationSnapshotPreview: typeof import('./components/collections/documentation/SnapshotPreview.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']
|
||||
|
|
@ -166,6 +170,7 @@ declare module 'vue' {
|
|||
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
|
||||
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
|
||||
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
|
||||
HoppSmartSelectItem: typeof import('@hoppscotch/ui')['HoppSmartSelectItem']
|
||||
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
|
||||
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
|
||||
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
||||
|
|
@ -231,15 +236,20 @@ declare module 'vue' {
|
|||
HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default']
|
||||
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
|
||||
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
|
||||
IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default']
|
||||
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']
|
||||
IconLucideBookOpen: typeof import('~icons/lucide/book-open')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideCheck: typeof import('~icons/lucide/check')['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']
|
||||
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default']
|
||||
IconLucideFileText: typeof import('~icons/lucide/file-text')['default']
|
||||
IconLucideFileX: typeof import('~icons/lucide/file-x')['default']
|
||||
IconLucideFolder: typeof import('~icons/lucide/folder')['default']
|
||||
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
|
|
@ -252,6 +262,7 @@ declare module 'vue' {
|
|||
IconLucideLock: typeof import('~icons/lucide/lock')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
|
||||
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideTerminal: typeof import('~icons/lucide/terminal')['default']
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
<div
|
||||
class="rounded-md border border-divider"
|
||||
:class="{
|
||||
' w-64': isDocModal,
|
||||
'w-64': isDocModal,
|
||||
'w-full': compact,
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
class="font-medium text-secondaryDark flex flex-1 items-center text-xs px-4 truncate cursor-pointer transition-colors"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<span class="truncate">
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{
|
||||
'!max-h-[400px]': isDocModal,
|
||||
'max-h-[400px]': isDocModal,
|
||||
}"
|
||||
>
|
||||
<div v-if="hasItems(collectionFolders)">
|
||||
|
|
@ -97,10 +98,12 @@ const props = withDefaults(
|
|||
collection: HoppCollection
|
||||
initiallyExpanded?: boolean
|
||||
isDocModal?: boolean
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{
|
||||
initiallyExpanded: false,
|
||||
isDocModal: true,
|
||||
compact: false,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
<template>
|
||||
<div>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => pickerActions?.focus()"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLayers"
|
||||
:label="selectedLabel"
|
||||
class="flex-1 !justify-start pr-8"
|
||||
outline
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="pickerActions"
|
||||
role="menu"
|
||||
class="flex flex-col space-y-2 focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="filterText"
|
||||
:placeholder="`${t('action.search')}`"
|
||||
:context-menu-enabled="false"
|
||||
class="border border-dividerDark focus:border-primaryDark rounded"
|
||||
/>
|
||||
|
||||
<!-- No environment option -->
|
||||
<HoppSmartItem
|
||||
:label="t('documentation.publish.no_environment')"
|
||||
:info-icon="!modelValue ? IconCheck : undefined"
|
||||
:active-info-icon="!modelValue"
|
||||
@click="
|
||||
() => {
|
||||
$emit('update:modelValue', null)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex flex-col items-center justify-center p-4"
|
||||
>
|
||||
<HoppSmartSpinner class="my-2" />
|
||||
<span class="text-secondaryLight text-xs">
|
||||
{{ t("state.loading") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col space-y-1 max-h-48 overflow-y-auto">
|
||||
<HoppSmartItem
|
||||
v-for="env in filteredEnvironments"
|
||||
:key="env.id"
|
||||
:icon="IconLayers"
|
||||
:label="env.name"
|
||||
:info-icon="modelValue === env.id ? IconCheck : undefined"
|
||||
:active-info-icon="modelValue === env.id"
|
||||
@click="
|
||||
() => {
|
||||
$emit('update:modelValue', env.id)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="filteredEnvironments.length === 0 && !isLoading"
|
||||
class="break-words"
|
||||
:alt="
|
||||
filterText
|
||||
? `${t('empty.search_environment')}`
|
||||
: t('empty.environments')
|
||||
"
|
||||
:text="
|
||||
filterText
|
||||
? `${t('empty.search_environment')} '${filterText}'`
|
||||
: t('empty.environments')
|
||||
"
|
||||
>
|
||||
<template v-if="filterText" #icon>
|
||||
<icon-lucide-search class="svg-icons opacity-75" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { environments$ } from "~/newstore/environments"
|
||||
import { WorkspaceType } from "~/helpers/backend/graphql"
|
||||
import { runGQLQuery } from "~/helpers/backend/GQLClient"
|
||||
import { GetTeamEnvironmentsDocument } from "~/helpers/backend/graphql"
|
||||
import * as E from "fp-ts/Either"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
|
||||
type EnvironmentOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
workspaceType: WorkspaceType
|
||||
workspaceID: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: "update:modelValue", value: string | null): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const filterText = ref("")
|
||||
const isLoading = ref(false)
|
||||
const availableEnvironments = ref<EnvironmentOption[]>([])
|
||||
|
||||
const personalEnvironments = useReadonlyStream(environments$, [])
|
||||
|
||||
const pickerActions = ref<HTMLElement | null>(null)
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
if (!props.modelValue) return t("documentation.publish.no_environment")
|
||||
const env = availableEnvironments.value.find((e) => e.id === props.modelValue)
|
||||
return env?.name ?? t("documentation.publish.no_environment")
|
||||
})
|
||||
|
||||
const filteredEnvironments = computed(() => {
|
||||
if (!filterText.value) return availableEnvironments.value
|
||||
|
||||
const trimmed = filterText.value.trim().toLowerCase()
|
||||
if (!trimmed) return []
|
||||
|
||||
return availableEnvironments.value.filter((env) =>
|
||||
env.name.toLowerCase().includes(trimmed)
|
||||
)
|
||||
})
|
||||
|
||||
const fetchEnvironments = async () => {
|
||||
isLoading.value = true
|
||||
availableEnvironments.value = []
|
||||
|
||||
try {
|
||||
if (props.workspaceType === WorkspaceType.Team && props.workspaceID) {
|
||||
const result = await runGQLQuery({
|
||||
query: GetTeamEnvironmentsDocument,
|
||||
variables: { teamID: props.workspaceID },
|
||||
})
|
||||
|
||||
if (E.isRight(result)) {
|
||||
const teamEnvs = (result.right as any).team?.teamEnvironments || []
|
||||
availableEnvironments.value = teamEnvs.map(
|
||||
(env: { id: string; name: string }) => ({
|
||||
id: env.id,
|
||||
name: env.name,
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
availableEnvironments.value = personalEnvironments.value
|
||||
.filter((env: Environment) => env.name)
|
||||
.map((env: Environment, index: number) => ({
|
||||
id: env.id || `personal-${index}`,
|
||||
name: env.name,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching environments:", error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch environments when workspace props change or environment list changes
|
||||
watch(
|
||||
[() => props.workspaceType, () => props.workspaceID, personalEnvironments],
|
||||
() => {
|
||||
fetchEnvironments()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
|
@ -158,6 +158,7 @@
|
|||
<div v-if="showAllDocumentation" class="p-4 sticky top-0">
|
||||
<CollectionsDocumentationCollectionStructure
|
||||
:collection="collection"
|
||||
:is-doc-modal="true"
|
||||
@request-select="handleRequestSelect"
|
||||
@folder-select="handleFolderSelect"
|
||||
@scroll-to-top="handleScrollToTop"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div>
|
||||
<HoppSmartInput
|
||||
v-model="titleModel"
|
||||
:label="t('documentation.publish.doc_title')"
|
||||
type="text"
|
||||
input-styles="floating-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFirstPublish"
|
||||
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-green-500/5 border border-green-500/15"
|
||||
>
|
||||
<icon-lucide-info
|
||||
class="w-3.5 h-3.5 text-green-600 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span class="text-xs text-green-600 leading-relaxed">
|
||||
{{ t("documentation.publish.first_publish_hint") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Version Input (hidden for first publish) -->
|
||||
<div v-if="!isFirstPublish">
|
||||
<HoppSmartInput
|
||||
v-model="versionModel"
|
||||
:label="t('documentation.publish.doc_version')"
|
||||
:disabled="mode === 'update'"
|
||||
:input-styles="[
|
||||
'floating-input',
|
||||
!isValidVersion && versionModel.length > 0
|
||||
? '!border-red-500 !focus:border-red-500'
|
||||
: '',
|
||||
]"
|
||||
/>
|
||||
<span
|
||||
v-if="!isValidVersion && versionModel.length > 0"
|
||||
class="text-xs text-red-500 mt-1 block"
|
||||
>
|
||||
{{ t("documentation.publish.invalid_version") }}
|
||||
</span>
|
||||
<span
|
||||
v-if="mode === 'create' && isValidVersion"
|
||||
class="text-xs text-secondaryLight mt-1 block"
|
||||
>
|
||||
{{ t("documentation.publish.snapshot_description") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-sync Toggle (hidden for first publish and for live versions) -->
|
||||
<div v-if="!isFirstPublish && !isAutoSyncLocked" class="flex items-start">
|
||||
<HoppSmartCheckbox
|
||||
:on="autoSyncModel"
|
||||
@change="autoSyncModel = !autoSyncModel"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm text-secondaryDark">
|
||||
{{ t("documentation.publish.auto_sync") }}
|
||||
</span>
|
||||
<span class="text-tiny text-secondaryLight">
|
||||
({{ t("documentation.publish.auto_sync_description") }})
|
||||
</span>
|
||||
</div>
|
||||
</HoppSmartCheckbox>
|
||||
</div>
|
||||
|
||||
<!-- Environment Selector -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-secondaryDark">
|
||||
{{ t("documentation.publish.environment") }}
|
||||
</span>
|
||||
<p class="text-xs text-secondaryLight">
|
||||
{{ t("documentation.publish.environment_description") }}
|
||||
</p>
|
||||
<p class="text-tiny text-secondaryLight !mt-1">
|
||||
{{ t("documentation.publish.sensitive_data_warning") }}
|
||||
</p>
|
||||
<CollectionsDocumentationEnvironmentPicker
|
||||
v-model="environmentModel"
|
||||
:workspace-type="workspaceType"
|
||||
:workspace-i-d="workspaceID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Published URL (shown after publishing or in update mode) -->
|
||||
<div v-if="publishedUrl || mode === 'update'" class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<HoppSmartInput
|
||||
:model-value="publishedUrl"
|
||||
:label="t('documentation.publish.published_url')"
|
||||
type="text"
|
||||
disabled
|
||||
input-styles="floating-input"
|
||||
class="flex-1 opacity-80 cursor-not-allowed"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="publishedUrl"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.copy_url')"
|
||||
:icon="IconCopy"
|
||||
outline
|
||||
@click="$emit('copyUrl')"
|
||||
/>
|
||||
<HoppButtonPrimary
|
||||
v-if="publishedUrl"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.open_published_doc')"
|
||||
:label="t('action.open')"
|
||||
:icon="IconExternalLink"
|
||||
@click="$emit('viewPublished')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { WorkspaceType } from "~/helpers/backend/graphql"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
publishTitle: string
|
||||
publishVersion: string
|
||||
autoSync: boolean
|
||||
selectedEnvironmentID: string | null
|
||||
publishedUrl: string | null
|
||||
isFirstPublish: boolean
|
||||
isAutoSyncLocked: boolean
|
||||
isValidVersion: boolean
|
||||
workspaceType: WorkspaceType
|
||||
workspaceID: string
|
||||
mode: "create" | "update"
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:publishTitle", value: string): void
|
||||
(e: "update:publishVersion", value: string): void
|
||||
(e: "update:autoSync", value: boolean): void
|
||||
(e: "update:selectedEnvironmentID", value: string | null): void
|
||||
(e: "copyUrl"): void
|
||||
(e: "viewPublished"): void
|
||||
}>()
|
||||
|
||||
const titleModel = computed({
|
||||
get: () => props.publishTitle,
|
||||
set: (v) => emit("update:publishTitle", v),
|
||||
})
|
||||
|
||||
const versionModel = computed({
|
||||
get: () => props.publishVersion,
|
||||
set: (v) => emit("update:publishVersion", v),
|
||||
})
|
||||
|
||||
const autoSyncModel = computed({
|
||||
get: () => props.autoSync,
|
||||
set: (v) => emit("update:autoSync", v),
|
||||
})
|
||||
|
||||
const environmentModel = computed({
|
||||
get: () => props.selectedEnvironmentID,
|
||||
set: (v) => emit("update:selectedEnvironmentID", v),
|
||||
})
|
||||
</script>
|
||||
|
|
@ -3,97 +3,41 @@
|
|||
v-if="show"
|
||||
dialog
|
||||
:title="modalTitle"
|
||||
styles="sm:max-w-2xl"
|
||||
:styles="mode === 'view' ? 'sm:max-w-6xl' : '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>
|
||||
<CollectionsDocumentationPublishDocSnapshotPreview
|
||||
v-if="mode === 'view'"
|
||||
:existing-data="existingData"
|
||||
:published-url="publishedUrl"
|
||||
:show="show && mode === 'view'"
|
||||
@copy-url="copyUrl"
|
||||
@view-published="viewPublished"
|
||||
/>
|
||||
|
||||
<!-- 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
|
||||
v-if="publishedUrl"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.copy_url')"
|
||||
:icon="copyIcon"
|
||||
outline
|
||||
@click="copyUrl"
|
||||
/>
|
||||
<HoppButtonPrimary
|
||||
v-if="(mode === 'view' || mode === 'update') && publishedUrl"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.open_published_doc')"
|
||||
:label="t('action.open')"
|
||||
:icon="IconExternalLink"
|
||||
@click="viewPublished"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Create/Update mode: Form -->
|
||||
<CollectionsDocumentationPublishDocForm
|
||||
v-else
|
||||
v-model:publish-title="publishTitle"
|
||||
v-model:publish-version="publishVersion"
|
||||
v-model:auto-sync="autoSync"
|
||||
v-model:selected-environment-i-d="selectedEnvironmentID"
|
||||
:published-url="publishedUrl"
|
||||
:is-first-publish="isFirstPublish ?? false"
|
||||
:is-auto-sync-locked="isAutoSyncLocked ?? false"
|
||||
:is-valid-version="isValidVersion"
|
||||
:workspace-type="workspaceType"
|
||||
:workspace-i-d="workspaceID"
|
||||
:mode="mode === 'update' ? 'update' : 'create'"
|
||||
@copy-url="copyUrl"
|
||||
@view-published="viewPublished"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-between items-center flex-1">
|
||||
<div class="flex items-center w-full space-x-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<HoppButtonPrimary
|
||||
v-if="mode === 'create' && !publishedUrl"
|
||||
:label="t('documentation.publish.button')"
|
||||
|
|
@ -109,18 +53,17 @@
|
|||
@click="handleUpdate"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
:label="mode === 'view' ? t('action.close') : t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div v-if="mode === 'update' || mode === 'view'" class="flex">
|
||||
<HoppButtonSecondary
|
||||
v-if="mode === 'update'"
|
||||
:icon="IconTrash2"
|
||||
:label="t('documentation.publish.unpublish')"
|
||||
class="!text-red-500"
|
||||
class="!text-red-500 hover:!bg-red-500/10"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
filled
|
||||
|
|
@ -143,14 +86,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, markRaw } from "vue"
|
||||
import { ref, computed, watch } 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 {
|
||||
|
|
@ -158,7 +98,8 @@ import {
|
|||
UpdatePublishedDocsArgs,
|
||||
WorkspaceType,
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { refAutoReset, useClipboard } from "@vueuse/core"
|
||||
import { useClipboard } from "@vueuse/core"
|
||||
import { CURRENT_VERSION_TAG } from "~/services/documentation.service"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
|
@ -170,12 +111,16 @@ const props = defineProps<{
|
|||
workspaceType: WorkspaceType
|
||||
workspaceID: string
|
||||
mode?: "create" | "update" | "view"
|
||||
isFirstPublish?: boolean
|
||||
isAutoSyncLocked?: boolean
|
||||
publishedDocId?: string
|
||||
existingData?: {
|
||||
title: string
|
||||
version: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
environmentName?: string | null
|
||||
environmentID?: string | null
|
||||
}
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
|
@ -189,11 +134,13 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const publishTitle = ref(props.existingData?.title || props.collectionTitle)
|
||||
const publishVersion = ref(props.existingData?.version || "latest")
|
||||
const autoSync = ref(props.existingData?.autoSync ?? true)
|
||||
const publishVersion = ref(props.existingData?.version || CURRENT_VERSION_TAG)
|
||||
const autoSync = ref(props.existingData?.autoSync ?? false)
|
||||
const publishedUrl = ref<string | null>(props.existingData?.url || null)
|
||||
const selectedEnvironmentID = ref<string | null>(
|
||||
props.existingData?.environmentID ?? null
|
||||
)
|
||||
|
||||
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const showDeleteConfirmModal = ref(false)
|
||||
|
|
@ -205,15 +152,23 @@ const initializeFormData = () => {
|
|||
publishVersion.value = props.existingData.version
|
||||
autoSync.value = props.existingData.autoSync
|
||||
publishedUrl.value = props.existingData.url
|
||||
} else {
|
||||
selectedEnvironmentID.value = props.existingData.environmentID ?? null
|
||||
} else if (props.isFirstPublish) {
|
||||
publishTitle.value = props.collectionTitle
|
||||
publishVersion.value = "1.0.0"
|
||||
publishVersion.value = CURRENT_VERSION_TAG
|
||||
autoSync.value = true
|
||||
publishedUrl.value = null
|
||||
selectedEnvironmentID.value = null
|
||||
} else {
|
||||
publishTitle.value = props.collectionTitle
|
||||
publishVersion.value = ""
|
||||
autoSync.value = false
|
||||
publishedUrl.value = null
|
||||
selectedEnvironmentID.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes to existingData or modal visibility to update
|
||||
// Watch for modal open/close
|
||||
watch(
|
||||
[() => props.existingData, () => props.show],
|
||||
([, isOpen]) => {
|
||||
|
|
@ -231,7 +186,13 @@ const modalTitle = computed(() => {
|
|||
})
|
||||
|
||||
const canPublish = computed(() => {
|
||||
return publishTitle.value.trim().length > 0
|
||||
return publishTitle.value.trim().length > 0 && isValidVersion.value
|
||||
})
|
||||
|
||||
const isValidVersion = computed(() => {
|
||||
const version = publishVersion.value.trim()
|
||||
const regex = /^[a-zA-Z0-9]+([.-][a-zA-Z0-9]+)*$/
|
||||
return version.length > 0 && regex.test(version)
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
|
|
@ -240,7 +201,8 @@ const hasChanges = computed(() => {
|
|||
return (
|
||||
publishTitle.value.trim() !== props.existingData.title ||
|
||||
publishVersion.value.trim() !== props.existingData.version ||
|
||||
autoSync.value !== props.existingData.autoSync
|
||||
autoSync.value !== props.existingData.autoSync ||
|
||||
selectedEnvironmentID.value !== (props.existingData.environmentID ?? null)
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -259,6 +221,7 @@ const handlePublish = () => {
|
|||
workspaceID: props.workspaceID,
|
||||
collectionID: props.collectionID,
|
||||
metadata: "{}",
|
||||
environmentID: selectedEnvironmentID.value || undefined,
|
||||
}
|
||||
|
||||
emit("publish", doc)
|
||||
|
|
@ -272,6 +235,7 @@ const handleUpdate = () => {
|
|||
version: publishVersion.value.trim(),
|
||||
autoSync: autoSync.value,
|
||||
metadata: "{}",
|
||||
environmentID: selectedEnvironmentID.value ?? null,
|
||||
}
|
||||
|
||||
emit("update", props.publishedDocId, doc)
|
||||
|
|
@ -279,7 +243,6 @@ const handleUpdate = () => {
|
|||
|
||||
const copyUrl = () => {
|
||||
if (publishedUrl.value) {
|
||||
copyIcon.value = markRaw(IconCheck)
|
||||
copy(publishedUrl.value)
|
||||
toast.success(t("documentation.publish.url_copied"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,406 @@
|
|||
<template>
|
||||
<div class="flex flex-col lg:flex-row gap-4 flex-1">
|
||||
<!-- Left Metadata Panel -->
|
||||
<div class="lg:w-72 flex-shrink-0 flex flex-col space-y-3">
|
||||
<!-- Title & version header -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold text-secondaryDark truncate">
|
||||
{{ existingData?.title }}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="text-xs text-secondaryLight rounded border border-dividerDark px-2 py-0.5"
|
||||
>
|
||||
{{ existingData?.version }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Environment badge -->
|
||||
<div
|
||||
v-if="existingData?.environmentName"
|
||||
class="flex items-center space-x-1.5"
|
||||
>
|
||||
<icon-lucide-layers
|
||||
class="w-3 h-3 text-secondaryLight flex-shrink-0"
|
||||
/>
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ existingData.environmentName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-divider" />
|
||||
|
||||
<!-- Published URL -->
|
||||
<div class="space-y-3">
|
||||
<div v-if="publishedUrl" class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-semibold uppercase tracking-wider text-secondaryLight"
|
||||
>
|
||||
{{ t("documentation.publish.published_url") }}
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center rounded border border-divider bg-primaryLight"
|
||||
>
|
||||
<span
|
||||
class="flex-1 px-2.5 py-1.5 text-xs text-secondary truncate select-all"
|
||||
>
|
||||
{{ publishedUrl }}
|
||||
</span>
|
||||
<div class="flex items-center border-l border-divider">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.copy_url')"
|
||||
:icon="copyIcon"
|
||||
class="!rounded-none !px-2 !py-1.5"
|
||||
@click="handleCopyUrl"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.open_published_doc')"
|
||||
:icon="IconExternalLink"
|
||||
class="!rounded-none !rounded-r !px-2 !py-1.5"
|
||||
@click="$emit('viewPublished')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status notice -->
|
||||
<div
|
||||
v-if="existingData && !isLive"
|
||||
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-primaryLight border border-divider"
|
||||
>
|
||||
<icon-lucide-lock
|
||||
class="w-3.5 h-3.5 text-secondaryLight flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span class="text-xs text-secondaryLight leading-relaxed">
|
||||
{{ t("documentation.publish.version_immutable") }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="existingData && isLive"
|
||||
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-green-500/5 border border-green-500/15"
|
||||
>
|
||||
<icon-lucide-refresh-cw
|
||||
class="w-3.5 h-3.5 text-green-600 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<span class="text-xs text-green-600 leading-relaxed">
|
||||
{{ t("documentation.publish.auto_sync_live_notice") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Snapshot Preview -->
|
||||
<div
|
||||
class="flex-1 min-w-0 rounded-md border border-divider flex flex-col overflow-hidden bg-primary"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="isLoadingSnapshot"
|
||||
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<HoppSmartSpinner />
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ t("documentation.publish.loading_snapshot") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="snapshotError"
|
||||
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<icon-lucide-alert-circle class="w-5 h-5 text-red-400" />
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ t("documentation.publish.snapshot_load_error") }}
|
||||
</span>
|
||||
<HoppButtonSecondary
|
||||
:label="t('documentation.publish.retry_snapshot')"
|
||||
:icon="IconRefreshCw"
|
||||
outline
|
||||
@click="fetchSnapshotPreview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="!snapshotCollectionData"
|
||||
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<icon-lucide-file-x class="w-5 h-5 text-secondaryLight" />
|
||||
<span class="text-xs text-secondaryLight">
|
||||
{{ t("documentation.publish.snapshot_empty") }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshot content -->
|
||||
<div v-else-if="snapshotCollectionData" class="h-[60vh] flex flex-col">
|
||||
<DocumentationContent
|
||||
:collection-data="snapshotCollectionData"
|
||||
:all-items="snapshotItems"
|
||||
:update-url-on-select="false"
|
||||
:compact="true"
|
||||
:is-doc-modal="true"
|
||||
:environment-variables="snapshotEnvironmentVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, markRaw, computed } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
Environment,
|
||||
translateToNewEnvironmentVariables,
|
||||
} from "@hoppscotch/data"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import {
|
||||
getPublishedDocBySlugREST,
|
||||
collectionFolderToHoppCollection,
|
||||
} from "~/helpers/backend/queries/PublishedDocs"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
import IconRefreshCw from "~icons/lucide/refresh-cw"
|
||||
import { isLiveVersion } from "~/services/documentation.service"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
type ExistingData = {
|
||||
title: string
|
||||
version: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
environmentName?: string | null
|
||||
environmentID?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
existingData?: ExistingData
|
||||
publishedUrl: string | null
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "copyUrl"): void
|
||||
(e: "viewPublished"): void
|
||||
}>()
|
||||
|
||||
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
copyIcon.value = markRaw(IconCheck)
|
||||
emit("copyUrl")
|
||||
}
|
||||
|
||||
// Snapshot state
|
||||
type SnapshotDocumentationItem = {
|
||||
id: string
|
||||
type: "folder" | "request"
|
||||
item: HoppCollection | HoppRESTRequest
|
||||
inheritedProperties: HoppInheritedProperty
|
||||
}
|
||||
|
||||
const isLoadingSnapshot = ref(false)
|
||||
const snapshotError = ref(false)
|
||||
const snapshotCollectionData = ref<HoppCollection | null>(null)
|
||||
const snapshotItems = ref<SnapshotDocumentationItem[]>([])
|
||||
const snapshotEnvironmentVariables = ref<Environment["variables"]>([])
|
||||
|
||||
/**
|
||||
* Checks whether the currently displayed published doc is the live (current) version.
|
||||
*/
|
||||
const isLive = computed(() => {
|
||||
if (!props.existingData) return true
|
||||
return isLiveVersion(props.existingData)
|
||||
})
|
||||
|
||||
/**
|
||||
* Extracts slug and version from a published doc URL
|
||||
*/
|
||||
const extractSlugFromUrl = (
|
||||
url: string
|
||||
): { slug: string; version: string } | null => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathParts = urlObj.pathname.split("/").filter(Boolean)
|
||||
const viewIndex = pathParts.indexOf("view")
|
||||
if (viewIndex !== -1 && pathParts.length > viewIndex + 2) {
|
||||
return {
|
||||
slug: pathParts[viewIndex + 1],
|
||||
version: pathParts[viewIndex + 2],
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const match = url.match(/\/view\/([^/]+)\/([^/]+)/)
|
||||
if (match) {
|
||||
return { slug: match[1], version: match[2] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flattens a collection into documentation items
|
||||
*/
|
||||
const flattenCollection = (
|
||||
collection: HoppCollection,
|
||||
items: SnapshotDocumentationItem[] = [],
|
||||
inheritedProperties: HoppInheritedProperty | undefined = undefined
|
||||
): SnapshotDocumentationItem[] => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the snapshot documentTree via REST and converts it for display
|
||||
*/
|
||||
const fetchSnapshotPreview = async () => {
|
||||
if (!props.existingData?.url) return
|
||||
|
||||
const parsed = extractSlugFromUrl(props.existingData.url)
|
||||
if (!parsed) {
|
||||
snapshotError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingSnapshot.value = true
|
||||
snapshotError.value = false
|
||||
snapshotCollectionData.value = null
|
||||
snapshotItems.value = []
|
||||
snapshotEnvironmentVariables.value = []
|
||||
|
||||
try {
|
||||
const result = await getPublishedDocBySlugREST(
|
||||
parsed.slug,
|
||||
parsed.version
|
||||
)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
snapshotError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Parse environment variables from the snapshot
|
||||
const rawEnvVars = result.right.environmentVariables
|
||||
if (rawEnvVars) {
|
||||
try {
|
||||
const parsedVars =
|
||||
typeof rawEnvVars === "string" ? JSON.parse(rawEnvVars) : rawEnvVars
|
||||
if (Array.isArray(parsedVars)) {
|
||||
snapshotEnvironmentVariables.value = parsedVars.map((v) => {
|
||||
const normalized = translateToNewEnvironmentVariables(v)
|
||||
// Ensure currentValue falls back to initialValue
|
||||
return {
|
||||
...normalized,
|
||||
currentValue: normalized.currentValue || normalized.initialValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing snapshot environment variables:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const publishedData = JSON.parse(result.right.documentTree)
|
||||
const hoppCollection = collectionFolderToHoppCollection(publishedData)
|
||||
snapshotCollectionData.value = hoppCollection
|
||||
snapshotItems.value = flattenCollection(hoppCollection)
|
||||
} catch (error) {
|
||||
console.error("Error loading snapshot preview:", error)
|
||||
snapshotError.value = true
|
||||
} finally {
|
||||
isLoadingSnapshot.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
snapshotCollectionData.value = null
|
||||
snapshotItems.value = []
|
||||
snapshotError.value = false
|
||||
snapshotEnvironmentVariables.value = []
|
||||
}
|
||||
|
||||
// Fetch snapshot when component becomes visible
|
||||
watch(
|
||||
() => props.show,
|
||||
(isVisible) => {
|
||||
if (isVisible && props.existingData?.url) {
|
||||
fetchSnapshotPreview()
|
||||
} else if (!isVisible) {
|
||||
cleanup()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
|
@ -44,7 +44,6 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Check performance issue -->
|
||||
<CollectionsDocumentationSectionsCurlView
|
||||
:request="request"
|
||||
:collection-i-d="collectionID"
|
||||
|
|
@ -53,6 +52,7 @@
|
|||
:request-index="requestIndex"
|
||||
:team-i-d="teamID"
|
||||
:inherited-properties="inheritedProperties"
|
||||
:environment-variables="environmentVariables"
|
||||
/>
|
||||
|
||||
<CollectionsDocumentationSectionsAuth
|
||||
|
|
@ -106,7 +106,6 @@ 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,
|
||||
|
|
@ -131,6 +130,7 @@ const props = withDefaults(
|
|||
teamID?: string
|
||||
readOnly?: boolean
|
||||
inheritedProperties?: HoppInheritedProperty
|
||||
environmentVariables?: Environment["variables"]
|
||||
}>(),
|
||||
{
|
||||
documentationDescription: "",
|
||||
|
|
@ -143,6 +143,7 @@ const props = withDefaults(
|
|||
teamID: undefined,
|
||||
readOnly: false,
|
||||
inheritedProperties: undefined,
|
||||
environmentVariables: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -241,6 +242,8 @@ const getEffectiveRequest = async () => {
|
|||
sourceEnv: "CollectionVariables",
|
||||
}) || e.initialValue,
|
||||
})),
|
||||
// Published doc environment variables (from attached environment)
|
||||
...(props.environmentVariables || []),
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -249,6 +252,7 @@ const getEffectiveRequest = async () => {
|
|||
...props.request,
|
||||
}),
|
||||
env,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
|
|
@ -357,19 +361,28 @@ function getMethodClass(method: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
const getFullEndpoint = computedAsync(async () => {
|
||||
const getFullEndpoint = ref("")
|
||||
|
||||
const updateFullEndpoint = async () => {
|
||||
const res = await getEffectiveRequest()
|
||||
|
||||
if (!res) return ""
|
||||
if (!res) {
|
||||
getFullEndpoint.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
const { effectiveRequest } = res
|
||||
|
||||
if (!effectiveRequest) return ""
|
||||
if (!effectiveRequest) {
|
||||
getFullEndpoint.value = ""
|
||||
return
|
||||
}
|
||||
|
||||
const input = effectiveRequest.effectiveFinalURL
|
||||
|
||||
if (!input) {
|
||||
return "https://"
|
||||
getFullEndpoint.value = "https://"
|
||||
return
|
||||
}
|
||||
|
||||
let url = input.trim()
|
||||
|
|
@ -386,8 +399,17 @@ const getFullEndpoint = computedAsync(async () => {
|
|||
url = (isLocalOrIP ? "http://" : "https://") + endpoint
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
getFullEndpoint.value = url
|
||||
}
|
||||
|
||||
// Re-compute endpoint when environment variables or request change
|
||||
watch(
|
||||
[() => props.environmentVariables, () => props.request],
|
||||
() => {
|
||||
updateFullEndpoint()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const copyToClipboard = async (text: string | undefined) => {
|
||||
if (!text) return
|
||||
|
|
|
|||
|
|
@ -69,77 +69,144 @@
|
|||
</span>
|
||||
|
||||
<div class="flex space-x-2 items-center">
|
||||
<!-- Publish Button - Simple button when not published -->
|
||||
<HoppButtonSecondary
|
||||
v-if="
|
||||
currentCollection && !isCollectionPublished && hasTeamWriteAccess
|
||||
"
|
||||
:icon="IconShare2"
|
||||
:label="t('documentation.publish.button')"
|
||||
outline
|
||||
filled
|
||||
@click="openPublishModal"
|
||||
/>
|
||||
<tippy
|
||||
v-else-if="
|
||||
currentCollection && isCollectionPublished && 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"
|
||||
<!-- Show published documentation button only when showAllDocumentation is true -->
|
||||
<div v-if="showAllDocumentation">
|
||||
<!-- Publish Button - when not published -->
|
||||
<HoppButtonSecondary
|
||||
v-if="
|
||||
currentCollection &&
|
||||
!isCollectionPublished &&
|
||||
hasTeamWriteAccess
|
||||
"
|
||||
:icon="IconShare2"
|
||||
:label="t('documentation.publish.button')"
|
||||
outline
|
||||
filled
|
||||
@click="openPublishModal"
|
||||
/>
|
||||
<tippy
|
||||
v-else-if="
|
||||
currentCollection && isCollectionPublished && hasTeamWriteAccess
|
||||
"
|
||||
ref="publishedDropdown"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="
|
||||
() => {
|
||||
publishedDropdownActions?.focus()
|
||||
scrollToActiveDoc()
|
||||
}
|
||||
"
|
||||
>
|
||||
<icon-lucide-globe class="svg-icons" />
|
||||
|
||||
<HoppButtonSecondary
|
||||
:icon="IconCheveronDown"
|
||||
reverse
|
||||
:label="t('documentation.publish.published')"
|
||||
class="!pr-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="publishedDropdownActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
class="flex items-center border border-accent pl-4 pr-2 rounded cursor-pointer"
|
||||
>
|
||||
<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"
|
||||
<icon-lucide-globe class="svg-icons" />
|
||||
|
||||
<HoppButtonSecondary
|
||||
:icon="IconCheveronDown"
|
||||
reverse
|
||||
:label="
|
||||
selectedVersionDoc?.version ||
|
||||
t('documentation.publish.published')
|
||||
"
|
||||
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">
|
||||
<HoppSmartItem
|
||||
:icon="IconPlus"
|
||||
:label="t('documentation.publish.create_new_version')"
|
||||
@click="
|
||||
() => {
|
||||
hide()
|
||||
createNewVersion()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('documentation.publish.copy_url')"
|
||||
:icon="copyIcon"
|
||||
@click="copyPublishedUrl"
|
||||
<div class="h-px bg-divider my-1"></div>
|
||||
<div
|
||||
v-if="publishedDocs.length > 0"
|
||||
ref="publishedDocsListRef"
|
||||
class="flex flex-col space-y-1 mb-2 max-h-32 overflow-y-auto"
|
||||
>
|
||||
<span
|
||||
class="text-tiny font-bold text-secondaryLight uppercase px-2"
|
||||
>
|
||||
{{ t("documentation.publish.versions") }}
|
||||
</span>
|
||||
<div
|
||||
v-for="doc in publishedDocs"
|
||||
:key="doc.id"
|
||||
ref="publishedDocItemRefs"
|
||||
class="px-2 py-1 rounded cursor-pointer hover:bg-primaryLight flex items-center justify-between"
|
||||
:class="{
|
||||
'text-accent': doc.id === selectedVersionDoc?.id,
|
||||
}"
|
||||
@click="handleVersionSelect(doc, hide)"
|
||||
>
|
||||
<span>{{ doc.version }}</span>
|
||||
<icon-lucide-check
|
||||
v-if="doc.id === selectedVersionDoc?.id"
|
||||
class="w-3 h-3 text-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-divider my-1"></div>
|
||||
<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('documentation.publish.copy_url')"
|
||||
:icon="copyIcon"
|
||||
@click="copyPublishedUrl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HoppSmartItem
|
||||
v-if="
|
||||
selectedVersionDoc && !isLiveVersion(selectedVersionDoc)
|
||||
"
|
||||
reverse
|
||||
:icon="IconEye"
|
||||
:label="t('documentation.publish.view_snapshot')"
|
||||
@click="
|
||||
() => {
|
||||
hide()
|
||||
openPublishModalForView()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-else
|
||||
reverse
|
||||
:icon="IconPenLine"
|
||||
:label="t('documentation.publish.edit_published_doc')"
|
||||
@click="
|
||||
() => {
|
||||
hide()
|
||||
openPublishModal()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HoppSmartItem
|
||||
reverse
|
||||
:icon="IconPenLine"
|
||||
:label="t('documentation.publish.edit_published_doc')"
|
||||
@click="
|
||||
() => {
|
||||
hide()
|
||||
openPublishModal()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
v-if="currentCollection"
|
||||
:icon="isDocumentationProcessing ? IconLoader2 : IconFileText"
|
||||
|
|
@ -169,6 +236,12 @@
|
|||
:workspace-type="isTeamCollection ? WorkspaceType.Team : WorkspaceType.User"
|
||||
:workspace-i-d="isTeamCollection ? teamID || '' : ''"
|
||||
:mode="publishModalMode"
|
||||
:is-first-publish="!isCollectionPublished && !isCreatingNewVersion"
|
||||
:is-auto-sync-locked="
|
||||
!!selectedVersionDoc &&
|
||||
isLiveVersion(selectedVersionDoc) &&
|
||||
!isCreatingNewVersion
|
||||
"
|
||||
:published-doc-id="publishedDocId"
|
||||
:existing-data="existingPublishedData"
|
||||
:loading="isProcessingPublish"
|
||||
|
|
@ -207,7 +280,9 @@ import { getErrorMessage } from "~/helpers/backend/mutations/MockServer"
|
|||
|
||||
import {
|
||||
DocumentationService,
|
||||
isLiveVersion,
|
||||
type DocumentationItem,
|
||||
type PublishedDocInfo,
|
||||
} from "~/services/documentation.service"
|
||||
|
||||
import IconFileText from "~icons/lucide/file-text"
|
||||
|
|
@ -217,6 +292,8 @@ 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 IconPlus from "~icons/lucide/plus"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
|
||||
import {
|
||||
WorkspaceType,
|
||||
|
|
@ -229,8 +306,6 @@ import {
|
|||
updatePublishedDoc,
|
||||
} from "~/helpers/backend/mutations/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"
|
||||
|
|
@ -282,30 +357,93 @@ const allItems = ref<Array<any>>([])
|
|||
|
||||
const showAllDocumentation = ref<boolean>(false)
|
||||
const showPublishModal = ref<boolean>(false)
|
||||
const isCreatingNewVersion = ref<boolean>(false)
|
||||
|
||||
const publishedDropdown = ref<TippyComponent | null>(null)
|
||||
const publishedDropdownActions = ref<HTMLDivElement | null>(null)
|
||||
const publishedDocsListRef = ref<HTMLDivElement | null>(null)
|
||||
const publishedDocItemRefs = ref<HTMLDivElement[]>([])
|
||||
|
||||
const scrollToActiveDoc = async () => {
|
||||
await nextTick()
|
||||
if (!selectedVersionDoc.value || !publishedDocsListRef.value) return
|
||||
|
||||
const index = publishedDocs.value.findIndex(
|
||||
(doc) => doc.id === selectedVersionDoc.value?.id
|
||||
)
|
||||
|
||||
if (index !== -1 && publishedDocItemRefs.value[index]) {
|
||||
publishedDocItemRefs.value[index].scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Published docs state
|
||||
const publishedDocStatus = computed(() => {
|
||||
if (!props.collectionID) return undefined
|
||||
return documentationService.getPublishedDocStatus(props.collectionID)
|
||||
const publishedDocs = computed(() => {
|
||||
if (!props.collectionID) return []
|
||||
return documentationService.getPublishedDocStatus(props.collectionID) || []
|
||||
})
|
||||
|
||||
const isCollectionPublished = computed(() => !!publishedDocStatus.value)
|
||||
const publishedDocId = computed(() => publishedDocStatus.value?.id)
|
||||
const selectedVersionDoc = ref<PublishedDocInfo | null>(null)
|
||||
|
||||
/**
|
||||
* Finds the CURRENT version from the published docs list.
|
||||
* The CURRENT version is the initial publish — identified by version string "CURRENT" (case-insensitive).
|
||||
* Falls back to the last doc (oldest, since the list is in descending order).
|
||||
*/
|
||||
const findCurrentVersion = (docs: PublishedDocInfo[]): PublishedDocInfo => {
|
||||
return docs.find((d) => isLiveVersion(d)) || docs[docs.length - 1]
|
||||
}
|
||||
|
||||
watch(
|
||||
publishedDocs,
|
||||
(docs) => {
|
||||
if (docs && docs.length > 0) {
|
||||
// If we already have a selected version, try to keep it (by ID)
|
||||
if (selectedVersionDoc.value) {
|
||||
const found = docs.find((d) => d.id === selectedVersionDoc.value?.id)
|
||||
if (found) {
|
||||
selectedVersionDoc.value = found
|
||||
return
|
||||
}
|
||||
}
|
||||
// Default to the CURRENT version since the editor always shows live content
|
||||
selectedVersionDoc.value = findCurrentVersion(docs)
|
||||
} else {
|
||||
selectedVersionDoc.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const isCollectionPublished = computed(() => publishedDocs.value.length > 0)
|
||||
const publishedDocId = computed(() => selectedVersionDoc.value?.id)
|
||||
const existingPublishedData = computed(() => {
|
||||
if (!publishedDocStatus.value) return undefined
|
||||
if (isCreatingNewVersion.value) return undefined
|
||||
if (!selectedVersionDoc.value) return undefined
|
||||
return {
|
||||
title: publishedDocStatus.value.title,
|
||||
version: publishedDocStatus.value.version,
|
||||
autoSync: publishedDocStatus.value.autoSync,
|
||||
url: publishedDocStatus.value.url,
|
||||
title: selectedVersionDoc.value.title,
|
||||
version: selectedVersionDoc.value.version,
|
||||
autoSync: selectedVersionDoc.value.autoSync,
|
||||
url: selectedVersionDoc.value.url,
|
||||
environmentName: selectedVersionDoc.value.environmentName ?? null,
|
||||
environmentID: selectedVersionDoc.value.environmentID ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const isViewingSnapshot = ref(false)
|
||||
|
||||
const publishModalMode = computed<"create" | "update" | "view">(() => {
|
||||
return isCollectionPublished.value ? "update" : "create"
|
||||
if (isCreatingNewVersion.value) return "create"
|
||||
if (isViewingSnapshot.value) return "view"
|
||||
// Only allow update mode for live versions; snapshot versions open in view mode
|
||||
if (isCollectionPublished.value) {
|
||||
return selectedVersionDoc.value && !isLiveVersion(selectedVersionDoc.value)
|
||||
? "view"
|
||||
: "update"
|
||||
}
|
||||
return "create"
|
||||
})
|
||||
|
||||
const isDocumentationProcessing = computed(() => {
|
||||
|
|
@ -408,10 +546,8 @@ const handleToggleAllDocumentation = async () => {
|
|||
// Reset fetched collection data when modal opens/closes
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
// No need to manually check published docs status as it is now reactive
|
||||
} else {
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
// Reset when modal closes
|
||||
fullCollectionData.value = null
|
||||
isLoadingTeamCollection.value = false
|
||||
|
|
@ -449,9 +585,51 @@ watch(
|
|||
)
|
||||
|
||||
const openPublishModal = () => {
|
||||
isViewingSnapshot.value = false
|
||||
showPublishModal.value = true
|
||||
}
|
||||
|
||||
const openPublishModalForView = () => {
|
||||
isViewingSnapshot.value = true
|
||||
showPublishModal.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles version selection from the dropdown.
|
||||
* For frozen (snapshot) versions, auto-opens the snapshot view modal.
|
||||
* For live versions, just selects them (user can then click Edit).
|
||||
*/
|
||||
const handleVersionSelect = (
|
||||
doc: PublishedDocInfo,
|
||||
hideDropdown: () => void
|
||||
) => {
|
||||
selectedVersionDoc.value = doc
|
||||
if (!isLiveVersion(doc)) {
|
||||
hideDropdown()
|
||||
openPublishModalForView()
|
||||
}
|
||||
}
|
||||
|
||||
const createNewVersion = () => {
|
||||
isCreatingNewVersion.value = true
|
||||
isViewingSnapshot.value = false
|
||||
showPublishModal.value = true
|
||||
}
|
||||
|
||||
watch(showPublishModal, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
// Reset selection back to the CURRENT version so the dropdown
|
||||
// label matches what the editor is actually showing
|
||||
if (isViewingSnapshot.value || isCreatingNewVersion.value) {
|
||||
if (publishedDocs.value.length > 0) {
|
||||
selectedVersionDoc.value = findCurrentVersion(publishedDocs.value)
|
||||
}
|
||||
}
|
||||
isCreatingNewVersion.value = false
|
||||
isViewingSnapshot.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const copyPublishedUrl = () => {
|
||||
if (existingPublishedData.value?.url) {
|
||||
copyIcon.value = markRaw(IconCheck)
|
||||
|
|
@ -785,8 +963,18 @@ const hideModal = () => {
|
|||
if (closeTimeout) clearTimeout(closeTimeout)
|
||||
}
|
||||
|
||||
const handlePublish = async (doc: CreatePublishedDocsArgs) => {
|
||||
const handlePublish = async (
|
||||
doc: CreatePublishedDocsArgs,
|
||||
environmentVariables?: string
|
||||
) => {
|
||||
isProcessingPublish.value = true
|
||||
|
||||
if (environmentVariables) {
|
||||
const metadata = JSON.parse(doc.metadata || "{}")
|
||||
metadata.environmentVariables = environmentVariables
|
||||
doc.metadata = JSON.stringify(metadata)
|
||||
}
|
||||
|
||||
await pipe(
|
||||
createPublishedDoc(doc),
|
||||
TE.match(
|
||||
|
|
@ -804,6 +992,13 @@ const handlePublish = async (doc: CreatePublishedDocsArgs) => {
|
|||
version: doc.version,
|
||||
autoSync: doc.autoSync,
|
||||
url: url,
|
||||
environmentName: data.createPublishedDoc.environmentName ?? null,
|
||||
environmentID: doc.environmentID ?? null,
|
||||
collection: {
|
||||
id: props.collectionID || "",
|
||||
},
|
||||
createdOn: data.createPublishedDoc.createdOn,
|
||||
updatedOn: data.createPublishedDoc.updatedOn,
|
||||
}
|
||||
|
||||
// Update service
|
||||
|
|
@ -813,14 +1008,29 @@ const handlePublish = async (doc: CreatePublishedDocsArgs) => {
|
|||
newDocInfo
|
||||
)
|
||||
}
|
||||
|
||||
// Select the new version and exit create mode
|
||||
selectedVersionDoc.value = newDocInfo
|
||||
isCreatingNewVersion.value = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
isProcessingPublish.value = false
|
||||
}
|
||||
|
||||
const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => {
|
||||
const handleUpdate = async (
|
||||
id: string,
|
||||
doc: UpdatePublishedDocsArgs,
|
||||
environmentVariables?: string
|
||||
) => {
|
||||
isProcessingPublish.value = true
|
||||
|
||||
if (environmentVariables) {
|
||||
const metadata = JSON.parse(doc.metadata || "{}")
|
||||
metadata.environmentVariables = environmentVariables
|
||||
doc.metadata = JSON.stringify(metadata)
|
||||
}
|
||||
|
||||
await pipe(
|
||||
updatePublishedDoc(id, doc),
|
||||
TE.match(
|
||||
|
|
@ -839,6 +1049,13 @@ const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => {
|
|||
version: data.updatePublishedDoc.version,
|
||||
autoSync: data.updatePublishedDoc.autoSync,
|
||||
url: url,
|
||||
environmentName: data.updatePublishedDoc.environmentName ?? null,
|
||||
environmentID: doc.environmentID ?? null,
|
||||
collection: {
|
||||
id: props.collectionID || "",
|
||||
},
|
||||
createdOn: data.updatePublishedDoc.createdOn,
|
||||
updatedOn: data.updatePublishedDoc.updatedOn,
|
||||
}
|
||||
|
||||
// Update service
|
||||
|
|
@ -873,7 +1090,11 @@ const handleDelete = async () => {
|
|||
|
||||
// Update service
|
||||
if (props.collectionID) {
|
||||
documentationService.setPublishedDocStatus(props.collectionID, null)
|
||||
documentationService.setPublishedDocStatus(
|
||||
props.collectionID,
|
||||
null,
|
||||
publishedDocId.value
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ const props = withDefaults(
|
|||
requestIndex?: number | null
|
||||
teamID?: string
|
||||
inheritedProperties?: HoppInheritedProperty
|
||||
environmentVariables?: Environment["variables"]
|
||||
}>(),
|
||||
{
|
||||
request: null,
|
||||
|
|
@ -217,6 +218,7 @@ const props = withDefaults(
|
|||
folderPath: null,
|
||||
requestIndex: null,
|
||||
teamID: undefined,
|
||||
environmentVariables: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -336,6 +338,8 @@ const getEffectiveRequest = async () => {
|
|||
sourceEnv: "Global",
|
||||
} as AggregateEnvironment) || envVar.initialValue,
|
||||
})),
|
||||
// Published doc environment variables (from attached environment)
|
||||
...(props.environmentVariables || []),
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -344,6 +348,7 @@ const getEffectiveRequest = async () => {
|
|||
...props.request,
|
||||
}),
|
||||
env,
|
||||
true,
|
||||
true
|
||||
)
|
||||
|
||||
|
|
@ -352,6 +357,17 @@ const getEffectiveRequest = async () => {
|
|||
return result
|
||||
}
|
||||
|
||||
// Re-generate when environment variables change (e.g., environment toggle in header)
|
||||
watch(
|
||||
() => props.environmentVariables,
|
||||
() => {
|
||||
if (isVisible.value) {
|
||||
generateCurlCommand()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Lazy computed for cURL command
|
||||
const curlCommand = ref<string>("")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
<template>
|
||||
<main class="flex-1 flex overflow-hidden">
|
||||
<div class="w-80 border-r border-divider bg-primary overflow-y-auto h-full">
|
||||
<div
|
||||
:class="[
|
||||
'border-r border-divider bg-primary overflow-y-auto h-full flex-shrink-0',
|
||||
compact ? 'w-48' : 'w-80',
|
||||
]"
|
||||
>
|
||||
<CollectionsDocumentationCollectionStructure
|
||||
v-if="collectionData"
|
||||
:collection="collectionData"
|
||||
:is-doc-modal="false"
|
||||
:compact="compact"
|
||||
:is-doc-modal="isDocModal"
|
||||
@request-select="handleRequestSelect"
|
||||
@folder-select="handleFolderSelect"
|
||||
@scroll-to-top="handleScrollToTop"
|
||||
|
|
@ -52,6 +58,7 @@
|
|||
:collection-i-d="collectionData.id"
|
||||
:inherited-properties="getInheritedProperties(item)"
|
||||
:read-only="true"
|
||||
:environment-variables="environmentVariables"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,7 +69,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { PropType, ref, onMounted } from "vue"
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
|
||||
|
|
@ -86,6 +93,18 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
environmentVariables: {
|
||||
type: Array as PropType<Environment["variables"]>,
|
||||
default: () => [],
|
||||
},
|
||||
isDocModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -123,14 +142,29 @@ const handleFolderSelect = (folder: HoppCollection) => {
|
|||
|
||||
/**
|
||||
* Scrolls to a specific item by its ID
|
||||
* Uses the mainContentRef as the scroll container to avoid issues
|
||||
* when embedded in modals or other nested scroll contexts
|
||||
*/
|
||||
const scrollToItem = (id: string): void => {
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`doc-item-${id}`)
|
||||
const container = mainContentRef.value
|
||||
if (!container) return
|
||||
|
||||
// Manual escape for ID to ensure compatibility with older browsers
|
||||
const escapeId = (str: string) =>
|
||||
str.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "\\$&")
|
||||
|
||||
const element = container.querySelector(
|
||||
`#doc-item-${escapeId(id)}`
|
||||
) as HTMLElement | null
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const offset = elementRect.top - containerRect.top + container.scrollTop
|
||||
|
||||
container.scrollTo({
|
||||
top: offset - 14, // account for scroll-mt-14
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
})
|
||||
} else {
|
||||
console.error("Item not found:", id)
|
||||
|
|
|
|||
|
|
@ -13,15 +13,138 @@
|
|||
<span
|
||||
class="text-md font-bold text-secondaryDark px-6 py-1 rounded-full border border-dividerDark shadow"
|
||||
>
|
||||
{{ publishedDoc?.title || "Untitled Project" }}
|
||||
{{
|
||||
publishedDoc?.title || t("documentation.publish.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>
|
||||
<!-- Version dropdown (when multiple versions exist) -->
|
||||
<tippy
|
||||
v-if="versions.length"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => versionDropdownRef?.focus()"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md cursor-pointer transition-colors"
|
||||
:class="
|
||||
isCurrentDocLive
|
||||
? 'bg-green-500/10 text-green-600 hover:bg-green-500/20'
|
||||
: 'bg-accent/10 text-accent hover:bg-accent/20'
|
||||
"
|
||||
>
|
||||
{{
|
||||
isCurrentDocLive
|
||||
? t("documentation.publish.live")
|
||||
: `${publishedDoc?.version}`
|
||||
}}
|
||||
<icon-lucide-chevron-down class="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="versionDropdownRef"
|
||||
role="menu"
|
||||
class="flex flex-col focus:outline-none min-w-[180px]"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-for="ver in versions"
|
||||
:key="ver.id"
|
||||
:label="getVersionLabel(ver)"
|
||||
:info-icon="
|
||||
ver.version === publishedDoc?.version
|
||||
? IconCheck
|
||||
: undefined
|
||||
"
|
||||
:active-info-icon="ver.version === publishedDoc?.version"
|
||||
@click="
|
||||
() => {
|
||||
navigateToVersion(ver)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] font-semibold uppercase rounded mr-2"
|
||||
:class="
|
||||
isLiveVersion(ver)
|
||||
? 'bg-green-500/10 text-green-600'
|
||||
: 'bg-accent/10 text-accent'
|
||||
"
|
||||
>
|
||||
{{
|
||||
isLiveVersion(ver)
|
||||
? t("documentation.publish.live")
|
||||
: t("documentation.publish.snapshot")
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartItem>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Environment dropdown -->
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => envDropdownRef?.focus()"
|
||||
>
|
||||
<HoppSmartSelectWrapper>
|
||||
<HoppButtonSecondary
|
||||
:label="
|
||||
environmentEnabled
|
||||
? `${environmentName}`
|
||||
: t('documentation.publish.no_environment')
|
||||
"
|
||||
:icon="IconLayers"
|
||||
:icon-position="'left'"
|
||||
class="flex-1 !justify-start rounded-none pr-8"
|
||||
/>
|
||||
</HoppSmartSelectWrapper>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="envDropdownRef"
|
||||
role="menu"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-if="environmentName"
|
||||
:label="environmentName"
|
||||
:info-icon="environmentEnabled ? IconCheck : undefined"
|
||||
:active-info-icon="environmentEnabled"
|
||||
@click="
|
||||
() => {
|
||||
$emit('toggleEnvironment', true)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
:label="t('documentation.publish.no_environment')"
|
||||
:info-icon="!environmentEnabled ? IconCheck : undefined"
|
||||
:active-info-icon="!environmentEnabled"
|
||||
@click="
|
||||
() => {
|
||||
$emit('toggleEnvironment', false)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,10 +152,27 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useRouter } from "vue-router"
|
||||
import { computed, PropType, ref } from "vue"
|
||||
import { PublishedDocs } from "~/helpers/backend/graphql"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import { isLiveVersion } from "~/services/documentation.service"
|
||||
|
||||
defineProps({
|
||||
type PublishedDocVersion = {
|
||||
id: string
|
||||
slug: string
|
||||
version: string
|
||||
title: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
publishedDoc: {
|
||||
type: Object as PropType<Partial<PublishedDocs> | null>,
|
||||
default: null,
|
||||
|
|
@ -41,5 +181,57 @@ defineProps({
|
|||
type: String,
|
||||
default: "Hoppscotch",
|
||||
},
|
||||
versions: {
|
||||
type: Array as PropType<PublishedDocVersion[]>,
|
||||
default: () => [],
|
||||
},
|
||||
environmentName: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null,
|
||||
},
|
||||
environmentEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: "toggleEnvironment", enabled: boolean): void
|
||||
}>()
|
||||
|
||||
const versionDropdownRef = ref<HTMLElement | null>(null)
|
||||
const envDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* Checks whether the currently displayed published doc is the live (current) version.
|
||||
* This is true if the doc is auto-synced, has the CURRENT version identifier, or has version 1.0.0 (legacy).
|
||||
*/
|
||||
const isCurrentDocLive = computed(() => {
|
||||
if (!props.publishedDoc?.version) return true
|
||||
return isLiveVersion({
|
||||
autoSync: props.publishedDoc.autoSync ?? false,
|
||||
version: props.publishedDoc.version,
|
||||
})
|
||||
})
|
||||
|
||||
const getVersionLabel = (ver: PublishedDocVersion): string => {
|
||||
if (isLiveVersion(ver)) return t("documentation.publish.live")
|
||||
return `${ver.version}`
|
||||
}
|
||||
|
||||
const navigateToVersion = (ver: PublishedDocVersion) => {
|
||||
if (ver.version === props.publishedDoc?.version) return
|
||||
|
||||
// Extract path from the version URL and navigate (handles both absolute and relative URLs)
|
||||
try {
|
||||
const url = new URL(ver.url, window.location.origin)
|
||||
router.push(url.pathname)
|
||||
} catch {
|
||||
// Fallback: use regex to extract the path
|
||||
const match = ver.url.match(/\/view\/([^/]+)/)
|
||||
if (match) {
|
||||
router.push(match[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) {
|
|||
version
|
||||
autoSync
|
||||
url
|
||||
environmentName
|
||||
createdOn
|
||||
updatedOn
|
||||
workspaceType
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) {
|
|||
version
|
||||
autoSync
|
||||
url
|
||||
environmentName
|
||||
createdOn
|
||||
updatedOn
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ query PublishedDoc($id: ID!) {
|
|||
autoSync
|
||||
url
|
||||
metadata
|
||||
environmentName
|
||||
environmentVariables
|
||||
createdOn
|
||||
updatedOn
|
||||
creator {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ query TeamPublishedDocsList(
|
|||
version
|
||||
autoSync
|
||||
url
|
||||
documentTree
|
||||
environmentName
|
||||
collection {
|
||||
id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ query UserPublishedDocsList($skip: Int!, $take: Int!) {
|
|||
version
|
||||
autoSync
|
||||
url
|
||||
documentTree
|
||||
environmentName
|
||||
collection {
|
||||
id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type PublishedDocListItem = {
|
|||
version: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
environmentName?: string | null
|
||||
collection: {
|
||||
id: string
|
||||
}
|
||||
|
|
@ -47,6 +48,7 @@ export type PublishedDoc = PublishedDocListItem & {
|
|||
id: string
|
||||
title: string
|
||||
}
|
||||
versions?: PublishedDocListItem[]
|
||||
}
|
||||
|
||||
// Type for the GraphQL query response
|
||||
|
|
@ -63,6 +65,27 @@ export type CollectionFolder = {
|
|||
data?: string
|
||||
}
|
||||
|
||||
// Type for the versions list in the REST response source of truth: packages/hoppscotch-backend/src/published-docs/published-docs.model.ts
|
||||
export type PublishedDocsVersion = {
|
||||
id: string
|
||||
slug: string
|
||||
version: string
|
||||
title: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
createdOn: string
|
||||
updatedOn: string
|
||||
creatorUid: string
|
||||
metadata: string
|
||||
documentTree: string
|
||||
}
|
||||
|
||||
export type PublishedDocREST = PublishedDocsVersion & {
|
||||
versions?: PublishedDocsVersion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the data field (stringified JSON) to extract auth, headers, variables, and description
|
||||
* @param data The stringified JSON data from CollectionFolder
|
||||
|
|
@ -229,18 +252,21 @@ export const getPublishedDocByID = (id: string) =>
|
|||
|
||||
/**
|
||||
*
|
||||
* @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
|
||||
* @param slug - The slug of the published doc to fetch
|
||||
* @param version - The version of the published doc to fetch
|
||||
* @returns The published doc with the specified slug
|
||||
*/
|
||||
export const getPublishedDocByIDREST = (
|
||||
id: string
|
||||
//tree: "FULL" | "MINIMAL" = "FULL"
|
||||
export const getPublishedDocBySlugREST = (
|
||||
slug: string,
|
||||
version?: string
|
||||
): TE.TaskEither<GetPublishedDocError, PublishedDocs> =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_API_URL || ""
|
||||
const response = await fetch(`${backendUrl}/published-docs/${id}`)
|
||||
const url = version
|
||||
? `${backendUrl}/published-docs/${slug}/${version}`
|
||||
: `${backendUrl}/published-docs/${slug}`
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
|
|
|
|||
|
|
@ -355,13 +355,15 @@ export function getFinalBodyFromRequest(
|
|||
* @param request The request to source from
|
||||
* @param environment The environment to apply
|
||||
* @param showKeyIfSecret Whether to show the key if the value is a secret
|
||||
* @param showKeyIfNotFound Whether to show the key if the value is not found
|
||||
*
|
||||
* @returns An object with extra fields defining a complete request
|
||||
*/
|
||||
export async function getEffectiveRESTRequest(
|
||||
request: HoppRESTRequest,
|
||||
environment: Environment,
|
||||
showKeyIfSecret = false
|
||||
showKeyIfSecret = false,
|
||||
showKeyIfNotFound = false
|
||||
): Promise<EffectiveHoppRESTRequest> {
|
||||
const effectiveFinalHeaders = pipe(
|
||||
(
|
||||
|
|
@ -433,7 +435,8 @@ export async function getEffectiveRESTRequest(
|
|||
request.endpoint,
|
||||
environment.variables,
|
||||
false,
|
||||
showKeyIfSecret
|
||||
showKeyIfSecret,
|
||||
showKeyIfNotFound
|
||||
),
|
||||
effectiveFinalHeaders,
|
||||
effectiveFinalParams,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
<DocumentationHeader
|
||||
v-if="!loading && !error && publishedDoc"
|
||||
:published-doc="publishedDoc"
|
||||
:versions="availableVersions"
|
||||
:instance-display-name="instanceDisplayName"
|
||||
:environment-name="environmentName"
|
||||
:environment-enabled="environmentEnabled"
|
||||
@toggle-environment="handleEnvironmentToggle"
|
||||
/>
|
||||
|
||||
<DocumentationSkeleton v-if="loading" />
|
||||
|
|
@ -24,25 +28,36 @@
|
|||
<DocumentationContent
|
||||
v-else-if="collectionData"
|
||||
:collection-data="collectionData"
|
||||
:is-doc-modal="false"
|
||||
:all-items="allItems"
|
||||
:update-url-on-select="true"
|
||||
:environment-variables="environmentVariables"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue"
|
||||
import { ref, onMounted, computed, watch } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import {
|
||||
getPublishedDocByIDREST,
|
||||
getPublishedDocBySlugREST,
|
||||
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 {
|
||||
Environment,
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
translateToNewEnvironmentVariables,
|
||||
} from "@hoppscotch/data"
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { PublishedDocs } from "~/helpers/backend/graphql"
|
||||
import {
|
||||
PublishedDocREST,
|
||||
PublishedDocsVersion,
|
||||
} from "~/helpers/backend/queries/PublishedDocs"
|
||||
|
||||
import { getKernelMode } from "@hoppscotch/kernel"
|
||||
import { platform } from "~/platform"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
|
|
@ -77,11 +92,18 @@ const instanceDisplayName = computed(() => {
|
|||
return currentState.value.instance.displayName
|
||||
})
|
||||
|
||||
const publishedDoc = ref<Partial<PublishedDocs> | null>(null)
|
||||
const publishedDoc = ref<Partial<PublishedDocREST> | null>(null)
|
||||
const availableVersions = ref<PublishedDocsVersion[]>([])
|
||||
const collectionData = ref<HoppCollection | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const environmentVariables = ref<Environment["variables"]>([])
|
||||
// Store the original parsed env vars so we can restore them when toggling
|
||||
const parsedEnvironmentVariables = ref<Environment["variables"]>([])
|
||||
const environmentName = ref<string | null>(null)
|
||||
const environmentEnabled = ref(true)
|
||||
|
||||
type DocumentationItem = {
|
||||
id: string
|
||||
type: "folder" | "request"
|
||||
|
|
@ -164,47 +186,105 @@ const allItems = computed<DocumentationItem[]>(() => {
|
|||
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
|
||||
const fetchDocs = async (docId: string, version: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
if (!docId) {
|
||||
error.value = "No document ID provided"
|
||||
error.value = t("documentation.publish.no_doc_id")
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch published doc using REST API (public access, no authentication required)
|
||||
const result = await getPublishedDocByIDREST(docId)()
|
||||
// If version is provided, fetch that specific version; otherwise fetch latest
|
||||
const result = await getPublishedDocBySlugREST(docId, version)()
|
||||
|
||||
if (E.isLeft(result)) {
|
||||
console.error("Error fetching published doc:", result.left)
|
||||
error.value = "Published documentation not found"
|
||||
error.value = t("documentation.publish.not_found")
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
publishedDoc.value = {
|
||||
autoSync: false,
|
||||
autoSync: result.right.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,
|
||||
creatorUid: result.right.creatorUid,
|
||||
versions: result.right.versions,
|
||||
}
|
||||
|
||||
// Store environment name from the published doc response
|
||||
environmentName.value = result.right.environmentName ?? null
|
||||
|
||||
// Parse environment variables from the published doc response
|
||||
const rawEnvVars = result.right.environmentVariables
|
||||
if (rawEnvVars) {
|
||||
try {
|
||||
const parsed =
|
||||
typeof rawEnvVars === "string" ? JSON.parse(rawEnvVars) : rawEnvVars
|
||||
if (Array.isArray(parsed)) {
|
||||
parsedEnvironmentVariables.value = parsed.map((v) => {
|
||||
const normalized = translateToNewEnvironmentVariables(v)
|
||||
// Ensure currentValue falls back to initialValue
|
||||
return {
|
||||
...normalized,
|
||||
currentValue: normalized.currentValue || normalized.initialValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing environment variables:", e)
|
||||
parsedEnvironmentVariables.value = []
|
||||
}
|
||||
}
|
||||
|
||||
if (availableVersions.value.length === 0 && result.right.versions) {
|
||||
availableVersions.value = result.right.versions
|
||||
}
|
||||
|
||||
// Apply environment variables based on toggle state
|
||||
// Reset to enabled for new version fetches
|
||||
environmentEnabled.value = !!environmentName.value
|
||||
environmentVariables.value = environmentEnabled.value
|
||||
? parsedEnvironmentVariables.value
|
||||
: []
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const handleEnvironmentToggle = (enabled: boolean) => {
|
||||
environmentEnabled.value = enabled
|
||||
environmentVariables.value = enabled ? parsedEnvironmentVariables.value : []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const docId = route.params.id as string
|
||||
const version = route.params.version as string
|
||||
fetchDocs(docId, version)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.params.id, route.params.version],
|
||||
([newId, newVersion], [oldId, oldVersion]) => {
|
||||
if (newId !== oldId || newVersion !== oldVersion) {
|
||||
fetchDocs(newId as string, newVersion as string)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
usePageHead({
|
||||
title: computed(
|
||||
() => publishedDoc.value?.title || "Hoppscotch Documentation"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
RequestDocumentationItem,
|
||||
SetCollectionDocumentationOptions,
|
||||
SetRequestDocumentationOptions,
|
||||
isLiveVersion,
|
||||
CURRENT_VERSION_TAG,
|
||||
} from "../documentation.service"
|
||||
import {
|
||||
getUserPublishedDocs,
|
||||
|
|
@ -467,11 +469,13 @@ describe("DocumentationService", () => {
|
|||
const mockDocs = [
|
||||
{
|
||||
id: "doc-1",
|
||||
collection: { id: "col-1" },
|
||||
title: "Doc 1",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "url-1",
|
||||
url: "http://example.com/doc-1",
|
||||
collection: { id: "coll-1" },
|
||||
createdOn: new Date().toISOString(),
|
||||
updatedOn: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -481,25 +485,32 @@ describe("DocumentationService", () => {
|
|||
|
||||
await service.fetchUserPublishedDocs()
|
||||
|
||||
const status = service.getPublishedDocStatus("col-1")
|
||||
expect(status).toEqual({
|
||||
id: "doc-1",
|
||||
title: "Doc 1",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "url-1",
|
||||
})
|
||||
const status = service.getPublishedDocStatus("coll-1")
|
||||
expect(status).toEqual([
|
||||
{
|
||||
id: "doc-1",
|
||||
title: "Doc 1",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "http://example.com/doc-1",
|
||||
collection: { id: "coll-1" },
|
||||
createdOn: mockDocs[0].createdOn, // Use the generated date for comparison
|
||||
updatedOn: mockDocs[0].updatedOn, // Use the generated date for comparison
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should fetch team published docs and update map", async () => {
|
||||
const mockDocs = [
|
||||
{
|
||||
id: "doc-2",
|
||||
collection: { id: "col-2" },
|
||||
title: "Doc 2",
|
||||
version: "v2",
|
||||
autoSync: false,
|
||||
url: "url-2",
|
||||
collection: { id: "col-2" },
|
||||
createdOn: new Date().toISOString(),
|
||||
updatedOn: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -510,13 +521,19 @@ describe("DocumentationService", () => {
|
|||
await service.fetchTeamPublishedDocs("team-1")
|
||||
|
||||
const status = service.getPublishedDocStatus("col-2")
|
||||
expect(status).toEqual({
|
||||
id: "doc-2",
|
||||
title: "Doc 2",
|
||||
version: "v2",
|
||||
autoSync: false,
|
||||
url: "url-2",
|
||||
})
|
||||
|
||||
expect(status).toEqual([
|
||||
{
|
||||
id: "doc-2",
|
||||
title: "Doc 2",
|
||||
version: "v2",
|
||||
autoSync: false,
|
||||
url: "url-2",
|
||||
collection: { id: "col-2" },
|
||||
createdOn: mockDocs[0].createdOn,
|
||||
updatedOn: mockDocs[0].updatedOn,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle error when fetching user published docs", async () => {
|
||||
|
|
@ -556,11 +573,14 @@ describe("DocumentationService", () => {
|
|||
version: "v3",
|
||||
autoSync: true,
|
||||
url: "url-3",
|
||||
collection: { id: "col-3" },
|
||||
createdOn: "2023-01-03",
|
||||
updatedOn: "2023-01-03",
|
||||
}
|
||||
|
||||
service.setPublishedDocStatus("col-3", info)
|
||||
|
||||
expect(service.getPublishedDocStatus("col-3")).toEqual(info)
|
||||
expect(service.getPublishedDocStatus("col-3")).toEqual([info])
|
||||
})
|
||||
|
||||
it("should remove published doc status", () => {
|
||||
|
|
@ -570,6 +590,9 @@ describe("DocumentationService", () => {
|
|||
version: "v3",
|
||||
autoSync: true,
|
||||
url: "url-3",
|
||||
collection: { id: "col-3" },
|
||||
createdOn: "2023-01-03",
|
||||
updatedOn: "2023-01-03",
|
||||
}
|
||||
|
||||
service.setPublishedDocStatus("col-3", info)
|
||||
|
|
@ -583,22 +606,26 @@ describe("DocumentationService", () => {
|
|||
const slowDocs = [
|
||||
{
|
||||
id: "doc-slow",
|
||||
collection: { id: "col-1" },
|
||||
title: "Slow Doc",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "url-slow",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-01",
|
||||
updatedOn: "2023-01-01",
|
||||
},
|
||||
]
|
||||
|
||||
const fastDocs = [
|
||||
{
|
||||
id: "doc-fast",
|
||||
collection: { id: "col-1" },
|
||||
title: "Fast Doc",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-fast",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-02",
|
||||
updatedOn: "2023-01-02",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -622,26 +649,132 @@ describe("DocumentationService", () => {
|
|||
await secondCall
|
||||
|
||||
// Verify the fast response is applied
|
||||
expect(service.getPublishedDocStatus("col-1")).toEqual({
|
||||
id: "doc-fast",
|
||||
title: "Fast Doc",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-fast",
|
||||
})
|
||||
expect(service.getPublishedDocStatus("col-1")).toEqual([
|
||||
{
|
||||
id: "doc-fast",
|
||||
title: "Fast Doc",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-fast",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-02",
|
||||
updatedOn: "2023-01-02",
|
||||
},
|
||||
])
|
||||
|
||||
// Now resolve the slow request
|
||||
resolveSlow!(E.right(slowDocs as any))
|
||||
await firstCall
|
||||
|
||||
// Verify the state hasn't changed (slow response ignored)
|
||||
expect(service.getPublishedDocStatus("col-1")).toEqual({
|
||||
id: "doc-fast",
|
||||
title: "Fast Doc",
|
||||
expect(service.getPublishedDocStatus("col-1")).toEqual([
|
||||
{
|
||||
id: "doc-fast",
|
||||
title: "Fast Doc",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-fast",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-02",
|
||||
updatedOn: "2023-01-02",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should get published doc by version", () => {
|
||||
const info1 = {
|
||||
id: "doc-1",
|
||||
title: "Doc 1",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "url-1",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-01",
|
||||
updatedOn: "2023-01-01",
|
||||
}
|
||||
const info2 = {
|
||||
id: "doc-2",
|
||||
title: "Doc 2",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-fast",
|
||||
})
|
||||
url: "url-2",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-02",
|
||||
updatedOn: "2023-01-02",
|
||||
}
|
||||
|
||||
service.setPublishedDocStatus("col-1", info1)
|
||||
service.setPublishedDocStatus("col-1", info2)
|
||||
|
||||
expect(service.getPublishedDocByVersion("col-1", "v1")).toEqual(info1)
|
||||
expect(service.getPublishedDocByVersion("col-1", "v2")).toEqual(info2)
|
||||
expect(service.getPublishedDocByVersion("col-1", "v3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should remove specific published doc version", () => {
|
||||
const info1 = {
|
||||
id: "doc-1",
|
||||
title: "Doc 1",
|
||||
version: "v1",
|
||||
autoSync: true,
|
||||
url: "url-1",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-01",
|
||||
updatedOn: "2023-01-01",
|
||||
}
|
||||
const info2 = {
|
||||
id: "doc-2",
|
||||
title: "Doc 2",
|
||||
version: "v2",
|
||||
autoSync: true,
|
||||
url: "url-2",
|
||||
collection: { id: "col-1" },
|
||||
createdOn: "2023-01-02",
|
||||
updatedOn: "2023-01-02",
|
||||
}
|
||||
|
||||
service.setPublishedDocStatus("col-1", info1)
|
||||
service.setPublishedDocStatus("col-1", info2)
|
||||
|
||||
expect(service.getPublishedDocStatus("col-1")).toHaveLength(2)
|
||||
|
||||
service.setPublishedDocStatus("col-1", null, "doc-1")
|
||||
|
||||
const status = service.getPublishedDocStatus("col-1")
|
||||
expect(status).toHaveLength(1)
|
||||
expect(status![0]).toEqual(info2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isLiveVersion", () => {
|
||||
it("returns true when autoSync is true and version is CURRENT", () => {
|
||||
expect(
|
||||
isLiveVersion({ autoSync: true, version: CURRENT_VERSION_TAG })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("is case-insensitive for CURRENT tag", () => {
|
||||
expect(isLiveVersion({ autoSync: true, version: "current" })).toBe(true)
|
||||
expect(isLiveVersion({ autoSync: true, version: "Current" })).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for legacy 1.0.0 version with autoSync", () => {
|
||||
expect(isLiveVersion({ autoSync: true, version: "1.0.0" })).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when autoSync is false even if version is CURRENT", () => {
|
||||
expect(
|
||||
isLiveVersion({ autoSync: false, version: CURRENT_VERSION_TAG })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when autoSync is false for legacy 1.0.0", () => {
|
||||
expect(isLiveVersion({ autoSync: false, version: "1.0.0" })).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for a snapshot version string", () => {
|
||||
expect(isLiveVersion({ autoSync: true, version: "2.0.0" })).toBe(false)
|
||||
expect(isLiveVersion({ autoSync: false, version: "2.0.0" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export interface PublishedDocInfo {
|
|||
version: string
|
||||
autoSync: boolean
|
||||
url: string
|
||||
environmentName?: string | null
|
||||
environmentID?: string | null
|
||||
collection: {
|
||||
id: string
|
||||
}
|
||||
createdOn: string
|
||||
updatedOn: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,6 +95,25 @@ export interface SetRequestDocumentationOptions extends BaseDocumentationOptions
|
|||
requestData: HoppRESTRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* The string identifier for the current live version of documentation.
|
||||
* The initial version of a published doc will 'CURRENT'
|
||||
*/
|
||||
export const CURRENT_VERSION_TAG = "CURRENT"
|
||||
|
||||
/**
|
||||
* Checks whether a published doc version is the live (current) version.
|
||||
* A live version is auto-synced, has the CURRENT version identifier,
|
||||
* or has version 1.0.0 (used in older versions of the project).
|
||||
* This version is in sync with the particular collection and will update if the collection is updated.
|
||||
*/
|
||||
export const isLiveVersion = (doc: {
|
||||
autoSync: boolean
|
||||
version: string
|
||||
}): boolean =>
|
||||
doc.autoSync &&
|
||||
(doc.version.toUpperCase() === CURRENT_VERSION_TAG || doc.version === "1.0.0")
|
||||
|
||||
/**
|
||||
* This service manages edited documentation for collections and requests.
|
||||
* It temporarily stores the edited documentation in a map for efficient saving.
|
||||
|
|
@ -106,7 +132,7 @@ export class DocumentationService extends Service {
|
|||
/**
|
||||
* Map to store published docs
|
||||
*/
|
||||
private publishedDocsMap = ref<Map<string, PublishedDocInfo>>(new Map())
|
||||
private publishedDocsMap = ref<Map<string, PublishedDocInfo[]>>(new Map())
|
||||
|
||||
/**
|
||||
* Counter to track the latest fetch request ID
|
||||
|
|
@ -265,16 +291,23 @@ export class DocumentationService extends Service {
|
|||
|
||||
if (E.isRight(result)) {
|
||||
const docs = result.right
|
||||
const newMap = new Map<string, PublishedDocInfo>()
|
||||
const newMap = new Map<string, PublishedDocInfo[]>()
|
||||
docs.forEach((doc) => {
|
||||
if (doc.collection?.id) {
|
||||
newMap.set(doc.collection.id, {
|
||||
const existing = newMap.get(doc.collection.id) || []
|
||||
existing.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
version: doc.version,
|
||||
autoSync: doc.autoSync,
|
||||
url: doc.url,
|
||||
collection: {
|
||||
id: doc.collection.id,
|
||||
},
|
||||
createdOn: doc.createdOn,
|
||||
updatedOn: doc.updatedOn,
|
||||
})
|
||||
newMap.set(doc.collection.id, existing)
|
||||
}
|
||||
})
|
||||
this.publishedDocsMap.value = newMap
|
||||
|
|
@ -304,16 +337,23 @@ export class DocumentationService extends Service {
|
|||
|
||||
if (E.isRight(result)) {
|
||||
const docs = result.right
|
||||
const newMap = new Map<string, PublishedDocInfo>()
|
||||
const newMap = new Map<string, PublishedDocInfo[]>()
|
||||
docs.forEach((doc) => {
|
||||
if (doc.collection?.id) {
|
||||
newMap.set(doc.collection.id, {
|
||||
const existing = newMap.get(doc.collection.id) || []
|
||||
existing.push({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
version: doc.version,
|
||||
autoSync: doc.autoSync,
|
||||
url: doc.url,
|
||||
collection: {
|
||||
id: doc.collection.id,
|
||||
},
|
||||
createdOn: doc.createdOn,
|
||||
updatedOn: doc.updatedOn,
|
||||
})
|
||||
newMap.set(doc.collection.id, existing)
|
||||
}
|
||||
})
|
||||
this.publishedDocsMap.value = newMap
|
||||
|
|
@ -328,28 +368,67 @@ export class DocumentationService extends Service {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the published status of a collection
|
||||
* Gets the published status of a collection (returns all versions)
|
||||
* @param collectionId The ID of the collection
|
||||
*/
|
||||
public getPublishedDocStatus(
|
||||
collectionId: string
|
||||
): PublishedDocInfo | undefined {
|
||||
): PublishedDocInfo[] | undefined {
|
||||
return this.publishedDocsMap.value.get(collectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific published doc version for a collection
|
||||
* @param collectionId The ID of the collection
|
||||
* @param version The version string to find
|
||||
*/
|
||||
public getPublishedDocByVersion(
|
||||
collectionId: string,
|
||||
version: string
|
||||
): PublishedDocInfo | undefined {
|
||||
const docs = this.publishedDocsMap.value.get(collectionId)
|
||||
return docs?.find((doc) => doc.version === version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually updates the published status of a collection
|
||||
* @param collectionId The ID of the collection
|
||||
* @param info The new info or null to remove
|
||||
* @param info The new info (single doc) to add/update, or null to remove ALL docs for this collection (use carefully)
|
||||
* @param removeId Optional ID to remove specifically
|
||||
*/
|
||||
public setPublishedDocStatus(
|
||||
collectionId: string,
|
||||
info: PublishedDocInfo | null
|
||||
info: PublishedDocInfo | null,
|
||||
removeId?: string
|
||||
) {
|
||||
if (info && removeId) {
|
||||
throw new Error(
|
||||
"setPublishedDocStatus: Cannot provide both 'info' and 'removeId'. Please call separately."
|
||||
)
|
||||
}
|
||||
|
||||
const newMap = new Map(this.publishedDocsMap.value)
|
||||
if (info) {
|
||||
newMap.set(collectionId, info)
|
||||
const existing = newMap.get(collectionId) || []
|
||||
|
||||
if (removeId) {
|
||||
const filtered = existing.filter((doc) => doc.id !== removeId)
|
||||
if (filtered.length > 0) {
|
||||
newMap.set(collectionId, filtered)
|
||||
} else {
|
||||
newMap.delete(collectionId)
|
||||
}
|
||||
} else if (info) {
|
||||
// Update or add
|
||||
const updated = [...existing]
|
||||
const index = updated.findIndex((doc) => doc.id === info.id)
|
||||
if (index !== -1) {
|
||||
updated[index] = info
|
||||
} else {
|
||||
updated.push(info)
|
||||
}
|
||||
newMap.set(collectionId, updated)
|
||||
} else {
|
||||
// Remove all if info is null and no removeId
|
||||
newMap.delete(collectionId)
|
||||
}
|
||||
this.publishedDocsMap.value = newMap
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@ export function parseTemplateStringE(
|
|||
str: string,
|
||||
variables: Environment["variables"],
|
||||
maskValue = false,
|
||||
showKeyIfSecret = false
|
||||
showKeyIfSecret = false,
|
||||
showKeyIfNotFound = false
|
||||
) {
|
||||
if (!variables || !str) {
|
||||
return E.right(str)
|
||||
|
|
@ -119,42 +120,55 @@ export function parseTemplateStringE(
|
|||
depth <= ENV_MAX_EXPAND_LIMIT &&
|
||||
!isSecret
|
||||
) {
|
||||
result = decodeURI(encodeURI(result)).replace(REGEX_ENV_VAR, (_, p1) => {
|
||||
// Prioritise predefined variable values over normal environment variables processing.
|
||||
const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find(
|
||||
(preVar) => preVar.key === p1
|
||||
)
|
||||
const currentResult = decodeURI(encodeURI(result)).replace(
|
||||
REGEX_ENV_VAR,
|
||||
(_, p1) => {
|
||||
// Prioritise predefined variable values over normal environment variables processing.
|
||||
const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find(
|
||||
(preVar) => preVar.key === p1
|
||||
)
|
||||
|
||||
if (foundPredefinedVar) {
|
||||
return foundPredefinedVar.getValue()
|
||||
}
|
||||
if (foundPredefinedVar) {
|
||||
return foundPredefinedVar.getValue()
|
||||
}
|
||||
|
||||
const variable = variables.find((x) => x && x.key === p1)
|
||||
const variable = variables.find((x) => x && x.key === p1)
|
||||
|
||||
if (variable && "currentValue" in variable) {
|
||||
// Show the key if it is a secret and explicitly specified
|
||||
if (variable.secret && showKeyIfSecret) {
|
||||
isSecret = true
|
||||
if (variable && "currentValue" in variable) {
|
||||
// Show the key if it is a secret and explicitly specified
|
||||
if (variable.secret && showKeyIfSecret) {
|
||||
isSecret = true
|
||||
return `<<${p1}>>`
|
||||
}
|
||||
// Mask the value if it is a secret and explicitly specified
|
||||
if (variable.secret && maskValue) {
|
||||
return "*".repeat(
|
||||
(
|
||||
variable as {
|
||||
secret: true
|
||||
initialValue: string
|
||||
currentValue: string
|
||||
key: string
|
||||
}
|
||||
).currentValue.length
|
||||
)
|
||||
}
|
||||
return variable.currentValue
|
||||
}
|
||||
|
||||
if (showKeyIfNotFound) {
|
||||
return `<<${p1}>>`
|
||||
}
|
||||
// Mask the value if it is a secret and explicitly specified
|
||||
if (variable.secret && maskValue) {
|
||||
return "*".repeat(
|
||||
(
|
||||
variable as {
|
||||
secret: true
|
||||
initialValue: string
|
||||
currentValue: string
|
||||
key: string
|
||||
}
|
||||
).currentValue.length
|
||||
)
|
||||
}
|
||||
return variable.currentValue
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
return ""
|
||||
}
|
||||
)
|
||||
|
||||
if (currentResult === result) {
|
||||
break
|
||||
}
|
||||
|
||||
result = currentResult
|
||||
depth++
|
||||
}
|
||||
|
||||
|
|
@ -179,10 +193,17 @@ export const parseTemplateString = (
|
|||
str: string,
|
||||
variables: Environment["variables"],
|
||||
maskValue = false,
|
||||
showKeyIfSecret = false
|
||||
showKeyIfSecret = false,
|
||||
showKeyIfNotFound = false
|
||||
) =>
|
||||
pipe(
|
||||
parseTemplateStringE(str, variables, maskValue, showKeyIfSecret),
|
||||
parseTemplateStringE(
|
||||
str,
|
||||
variables,
|
||||
maskValue,
|
||||
showKeyIfSecret,
|
||||
showKeyIfNotFound
|
||||
),
|
||||
E.getOrElse(() => str)
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue