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:
Mir Arif Hasan 2026-02-23 20:41:55 +06:00 committed by GitHub
parent faf2bfc8eb
commit 803e4633a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3446 additions and 485 deletions

View file

@ -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");

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "PublishedDocs" ADD COLUMN "environmentID" TEXT,
ADD COLUMN "environmentName" TEXT,
ADD COLUMN "environmentVariables" JSONB;

View file

@ -297,18 +297,25 @@ model MockServerActivity {
} }
model PublishedDocs { model PublishedDocs {
id String @id @default(cuid()) id String @id @default(cuid())
title String slug String
collectionID String title String
creatorUid String collectionID String
version String creatorUid String
autoSync Boolean version String
documentTree Json? // Optional if autoSync is true autoSync Boolean
workspaceType WorkspaceType documentTree Json? // Optional if autoSync is true
workspaceID String workspaceType WorkspaceType
metadata Json? workspaceID String
createdOn DateTime @default(now()) @db.Timestamptz(3) environmentID String?
updatedOn DateTime @updatedAt @db.Timestamptz(3) 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 { enum WorkspaceType {

View file

@ -975,6 +975,13 @@ export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed';
*/ */
export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_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 * Published Docs not found
* (PublishedDocsService) * (PublishedDocsService)

View file

@ -51,6 +51,15 @@ export class CreatePublishedDocsArgs {
description: 'Metadata associated with the published document', description: 'Metadata associated with the published document',
}) })
metadata: string; metadata: string;
@Field({
name: 'environmentID',
description:
'ID of the environment to associate with the published document',
nullable: true,
})
@IsOptional()
environmentID?: string;
} }
@InputType() @InputType()
@ -60,6 +69,7 @@ export class UpdatePublishedDocsArgs {
description: 'Title of the published document', description: 'Title of the published document',
nullable: true, nullable: true,
}) })
@IsOptional()
title?: string; title?: string;
@Field({ @Field({
@ -80,6 +90,7 @@ export class UpdatePublishedDocsArgs {
'Whether the published document should auto-sync with the source', 'Whether the published document should auto-sync with the source',
nullable: true, nullable: true,
}) })
@IsOptional()
autoSync?: boolean; autoSync?: boolean;
@Field({ @Field({
@ -87,5 +98,15 @@ export class UpdatePublishedDocsArgs {
description: 'Metadata associated with the published document', description: 'Metadata associated with the published document',
nullable: true, nullable: true,
}) })
@IsOptional()
metadata?: string; 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;
} }

View file

@ -2,14 +2,12 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
Query,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PublishedDocsService } from './published-docs.service'; import { PublishedDocsService } from './published-docs.service';
import { GetPublishedDocsQueryDto } from './published-docs.dto';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { throwHTTPErr } from 'src/utils'; import { throwHTTPErr } from 'src/utils';
import { PublishedDocs } from './published-docs.model'; import { PublishedDocs } from './published-docs.model';
@ -21,12 +19,12 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua
export class PublishedDocsController { export class PublishedDocsController {
constructor(private readonly publishedDocsService: PublishedDocsService) {} constructor(private readonly publishedDocsService: PublishedDocsService) {}
@Get(':docId') @Get(':slug')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({
summary: 'Get published documentation', summary: 'Get latest published documentation by slug',
description: 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({ @ApiResponse({
status: 200, status: 200,
@ -37,13 +35,42 @@ export class PublishedDocsController {
status: 404, status: 404,
description: 'Published documentation not found', description: 'Published documentation not found',
}) })
async getPublishedDocs( async getPublishedDocsBySlugLatest(@Param('slug') slug: string) {
@Param('docId') docId: string, const result = await this.publishedDocsService.getPublishedDocBySlugPublic(
@Query() query: GetPublishedDocsQueryDto, 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( const result = await this.publishedDocsService.getPublishedDocBySlugPublic(
docId, slug,
query, version,
); );
if (E.isLeft(result)) { if (E.isLeft(result)) {

View file

@ -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;
}

View file

@ -1,5 +1,69 @@
import { ObjectType, Field, ID } from '@nestjs/graphql'; import { ObjectType, Field, ID } from '@nestjs/graphql';
import { ApiProperty } from '@nestjs/swagger'; 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() @ObjectType()
export class PublishedDocs { export class PublishedDocs {
@ -10,13 +74,27 @@ export class PublishedDocs {
description: 'ID of the published API documentation', description: 'ID of the published API documentation',
example: 'doc_12345', example: 'doc_12345',
}) })
@Expose()
id: string; 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' }) @Field({ description: 'Title of the published API documentation' })
@ApiProperty({ @ApiProperty({
description: 'Title of the published API documentation', description: 'Title of the published API documentation',
example: 'My API Documentation', example: 'My API Documentation',
}) })
@Expose()
title: string; title: string;
@Field({ @Field({
@ -26,6 +104,7 @@ export class PublishedDocs {
description: 'URL where the published API documentation can be accessed', description: 'URL where the published API documentation can be accessed',
example: 'https://docs.example.com/api', example: 'https://docs.example.com/api',
}) })
@Expose()
url: string; url: string;
@Field({ description: 'Version of the published API documentation' }) @Field({ description: 'Version of the published API documentation' })
@ -33,6 +112,7 @@ export class PublishedDocs {
description: 'Version of the published API documentation', description: 'Version of the published API documentation',
example: '1.0.0', example: '1.0.0',
}) })
@Expose()
version: string; version: string;
@Field({ description: 'Indicates if the documentation is set to auto-sync' }) @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', description: 'Indicates if the documentation is set to auto-sync',
example: true, example: true,
}) })
@Expose()
autoSync: boolean; autoSync: boolean;
@Field({ @Field({
@ -50,6 +131,7 @@ export class PublishedDocs {
example: example:
'{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}', '{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}',
}) })
@Expose()
documentTree: string; documentTree: string;
@Field({ @Field({
@ -79,13 +161,42 @@ export class PublishedDocs {
description: 'Metadata of the documentation', description: 'Metadata of the documentation',
example: '{"author": "John Doe", "tags": ["api", "rest"]}', example: '{"author": "John Doe", "tags": ["api", "rest"]}',
}) })
@Expose()
metadata: string; 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' }) @Field({ description: 'Timestamp when the documentation was created' })
@ApiProperty({ @ApiProperty({
description: 'Timestamp when the documentation was created', description: 'Timestamp when the documentation was created',
example: '2024-01-01T00:00:00.000Z', example: '2024-01-01T00:00:00.000Z',
}) })
@Expose()
createdOn: Date; createdOn: Date;
@Field({ description: 'Timestamp when the documentation was last updated' }) @Field({ description: 'Timestamp when the documentation was last updated' })
@ -93,7 +204,20 @@ export class PublishedDocs {
description: 'Timestamp when the documentation was last updated', description: 'Timestamp when the documentation was last updated',
example: '2024-01-15T12:30:00.000Z', example: '2024-01-15T12:30:00.000Z',
}) })
@Expose()
updatedOn: Date; 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() @ObjectType()

View file

@ -9,7 +9,11 @@ import {
Query, Query,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; 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 { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { import {
@ -60,6 +64,20 @@ export class PublishedDocsResolver {
return collection.right; 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 // Queries
@Query(() => PublishedDocs, { @Query(() => PublishedDocs, {

View file

@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import { import {
CreatePublishedDocsArgs, CreatePublishedDocsArgs,
UpdatePublishedDocsArgs, UpdatePublishedDocsArgs,
@ -12,6 +13,7 @@ import {
PUBLISHED_DOCS_CREATION_FAILED, PUBLISHED_DOCS_CREATION_FAILED,
PUBLISHED_DOCS_DELETION_FAILED, PUBLISHED_DOCS_DELETION_FAILED,
PUBLISHED_DOCS_INVALID_COLLECTION, PUBLISHED_DOCS_INVALID_COLLECTION,
PUBLISHED_DOCS_INVALID_ENVIRONMENT,
PUBLISHED_DOCS_NOT_FOUND, PUBLISHED_DOCS_NOT_FOUND,
PUBLISHED_DOCS_UPDATE_FAILED, PUBLISHED_DOCS_UPDATE_FAILED,
TEAM_INVALID_COLL_ID, TEAM_INVALID_COLL_ID,
@ -19,13 +21,16 @@ import {
USER_COLL_NOT_FOUND, USER_COLL_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import * as E from 'fp-ts/Either'; 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 { OffsetPaginationArgs } from 'src/types/input-types.args';
import { stringToJson } from 'src/utils'; import { stringToJson } from 'src/utils';
import { UserCollectionService } from 'src/user-collection/user-collection.service'; import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service'; import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto';
import { ConfigService } from '@nestjs/config'; 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() @Injectable()
export class PublishedDocsService { export class PublishedDocsService {
@ -36,18 +41,83 @@ export class PublishedDocsService {
private readonly configService: ConfigService, 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 * Cast database PublishedDocs to GraphQL PublishedDocs
*/ */
private cast(doc: DbPublishedDocs): PublishedDocs { private cast(
doc: DbPublishedDocs,
versions: PublishedDocsVersion[] = [],
): PublishedDocs {
return { return {
...doc, ...doc,
versions,
documentTree: JSON.stringify(doc.documentTree), documentTree: JSON.stringify(doc.documentTree),
metadata: JSON.stringify(doc.metadata), 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 * 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); 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 * Get a published document by ID
*/ */
@ -215,19 +300,29 @@ export class PublishedDocsService {
} }
/** /**
* Get a published document by ID for public access (unauthenticated) * Get a published document by slug and version for public access (unauthenticated)
* @param id - The ID of the published document * @param slug - The slug of the published document
* @param query - Query parameters specifying tree level * @param version - The version of the published document
*/ */
async getPublishedDocByIDPublic( async getPublishedDocBySlugPublic(
id: string, slug: string,
query: GetPublishedDocsQueryDto, version: string | null,
): Promise<E.Either<string, PublishedDocs>> { ): 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({ 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); if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND);
let docToReturn = publishedDocs;
// if autoSync is enabled, fetch from the collection directly // if autoSync is enabled, fetch from the collection directly
if (publishedDocs.autoSync) { if (publishedDocs.autoSync) {
const collectionResult = const collectionResult =
@ -235,12 +330,10 @@ export class PublishedDocsService {
? await this.userCollectionService.exportUserCollectionToJSONObject( ? await this.userCollectionService.exportUserCollectionToJSONObject(
publishedDocs.creatorUid, publishedDocs.creatorUid,
publishedDocs.collectionID, publishedDocs.collectionID,
query.tree === TreeLevel.FULL,
) )
: await this.teamCollectionService.exportCollectionToJSONObject( : await this.teamCollectionService.exportCollectionToJSONObject(
publishedDocs.workspaceID, publishedDocs.workspaceID,
publishedDocs.collectionID, publishedDocs.collectionID,
query.tree === TreeLevel.FULL,
); );
if (E.isLeft(collectionResult)) { if (E.isLeft(collectionResult)) {
@ -258,15 +351,44 @@ export class PublishedDocsService {
return E.left(collectionResult.left); return E.left(collectionResult.left);
} }
return E.right( // Re-fetch environment if environmentID is set
this.cast({ let environmentName = publishedDocs.environmentName;
...publishedDocs, let environmentVariables = publishedDocs.environmentVariables;
documentTree: JSON.parse(JSON.stringify(collectionResult.right)),
}), 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) { if (docsToDelete.length > 0) {
const idsToDelete = docsToDelete.map((doc) => doc.id); const idsToDelete = docsToDelete.map((doc) => doc.id);
this.prisma.publishedDocs.deleteMany({ await this.prisma.publishedDocs.deleteMany({
where: { id: { in: idsToDelete } }, where: { id: { in: idsToDelete } },
}); });
} }
@ -383,7 +505,11 @@ export class PublishedDocsService {
* @param args - Arguments for creating the published document * @param args - Arguments for creating the published document
* @param user - The user 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 { try {
// Validate workspace type and ID // Validate workspace type and ID
const workspaceValidation = await this.validateWorkspace(user, { const workspaceValidation = await this.validateWorkspace(user, {
@ -408,25 +534,87 @@ export class PublishedDocsService {
const metadata = stringToJson(args.metadata); const metadata = stringToJson(args.metadata);
if (E.isLeft(metadata)) return E.left(metadata.left); 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({ const newPublishedDoc = await this.prisma.publishedDocs.create({
data: { data: {
title: args.title, title: args.title,
slug: slug,
collectionID: args.collectionID, collectionID: args.collectionID,
creatorUid: user.uid, creatorUid: user.uid,
version: args.version, version: args.version,
autoSync: args.autoSync, autoSync: args.autoSync,
workspaceType: args.workspaceType, workspaceType: args.workspaceType,
workspaceID: workspaceID: workspaceID,
args.workspaceType === WorkspaceType.TEAM documentTree: documentTree as unknown as JsonValue,
? args.workspaceID
: user.uid,
metadata: metadata.right, metadata: metadata.right,
environmentID: args.environmentID ?? null,
environmentName,
environmentVariables,
}, },
}); });
return E.right(this.cast(newPublishedDoc)); return E.right(this.cast(newPublishedDoc));
} catch (error) { } 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); console.error('Error creating published document:', error);
return E.left(PUBLISHED_DOCS_CREATION_FAILED); return E.left(PUBLISHED_DOCS_CREATION_FAILED);
} }
@ -464,6 +652,59 @@ export class PublishedDocsService {
if (E.isLeft(metadata)) return E.left(metadata.left); 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 // Update published document
const updatedPublishedDoc = await this.prisma.publishedDocs.update({ const updatedPublishedDoc = await this.prisma.publishedDocs.update({
where: { id }, where: { id },
@ -471,8 +712,20 @@ export class PublishedDocsService {
title: args.title, title: args.title,
version: args.version, version: args.version,
autoSync: args.autoSync, autoSync: args.autoSync,
documentTree:
documentTree !== undefined
? (documentTree as unknown as JsonValue)
: undefined,
metadata: metadata:
metadata && E.isRight(metadata) ? metadata.right : undefined, metadata && E.isRight(metadata) ? metadata.right : undefined,
environmentID:
environmentID !== undefined ? environmentID : undefined,
environmentName:
environmentName !== undefined ? environmentName : undefined,
environmentVariables:
environmentVariables !== undefined
? environmentVariables
: undefined,
}, },
}); });

View file

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

View file

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

View file

@ -533,7 +533,33 @@
"update_title": "Update Published Documentation", "update_title": "Update Published Documentation",
"url_copied": "URL copied to clipboard!", "url_copied": "URL copied to clipboard!",
"view_published": "View Published Docs", "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!", "request_opened_in_new_tab": "Request opened in new tab!",
"response": { "response": {

View file

@ -56,11 +56,14 @@ declare module 'vue' {
CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default'] CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default']
CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default'] CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default']
CollectionsDocumentationCollectionStructure: typeof import('./components/collections/documentation/CollectionStructure.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'] CollectionsDocumentationFolderItem: typeof import('./components/collections/documentation/FolderItem.vue')['default']
CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default'] CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default']
CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default'] CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default']
CollectionsDocumentationPreview: typeof import('./components/collections/documentation/Preview.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'] 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'] CollectionsDocumentationRequestItem: typeof import('./components/collections/documentation/RequestItem.vue')['default']
CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default'] CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default']
CollectionsDocumentationSectionsAuth: typeof import('./components/collections/documentation/sections/Auth.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'] CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default']
CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default'] CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default']
CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.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'] CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default'] CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default'] CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@ -166,6 +170,7 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio'] HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectItem: typeof import('@hoppscotch/ui')['HoppSmartSelectItem']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper'] HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
@ -231,15 +236,20 @@ declare module 'vue' {
HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default'] HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default'] IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBookOpen: typeof import('~icons/lucide/book-open')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default'] IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default'] IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default']
IconLucideFileText: typeof import('~icons/lucide/file-text')['default'] IconLucideFileText: typeof import('~icons/lucide/file-text')['default']
IconLucideFileX: typeof import('~icons/lucide/file-x')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default']
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@ -252,6 +262,7 @@ declare module 'vue' {
IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideTerminal: typeof import('~icons/lucide/terminal')['default'] IconLucideTerminal: typeof import('~icons/lucide/terminal')['default']

View file

@ -2,14 +2,15 @@
<div <div
class="rounded-md border border-divider" class="rounded-md border border-divider"
:class="{ :class="{
' w-64': isDocModal, 'w-64': isDocModal,
'w-full': compact,
}" }"
> >
<div <div
class="sticky top-0 z-[99] py-2 border-b border-divider bg-primaryLight flex items-center justify-between space-x-3" class="sticky top-0 z-[99] py-2 border-b border-divider bg-primaryLight flex items-center justify-between space-x-3"
> >
<div <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" @click="scrollToTop"
> >
<span class="truncate"> <span class="truncate">
@ -28,7 +29,7 @@
<div <div
class="overflow-y-auto" class="overflow-y-auto"
:class="{ :class="{
'!max-h-[400px]': isDocModal, 'max-h-[400px]': isDocModal,
}" }"
> >
<div v-if="hasItems(collectionFolders)"> <div v-if="hasItems(collectionFolders)">
@ -97,10 +98,12 @@ const props = withDefaults(
collection: HoppCollection collection: HoppCollection
initiallyExpanded?: boolean initiallyExpanded?: boolean
isDocModal?: boolean isDocModal?: boolean
compact?: boolean
}>(), }>(),
{ {
initiallyExpanded: false, initiallyExpanded: false,
isDocModal: true, isDocModal: true,
compact: false,
} }
) )

View file

@ -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>

View file

@ -158,6 +158,7 @@
<div v-if="showAllDocumentation" class="p-4 sticky top-0"> <div v-if="showAllDocumentation" class="p-4 sticky top-0">
<CollectionsDocumentationCollectionStructure <CollectionsDocumentationCollectionStructure
:collection="collection" :collection="collection"
:is-doc-modal="true"
@request-select="handleRequestSelect" @request-select="handleRequestSelect"
@folder-select="handleFolderSelect" @folder-select="handleFolderSelect"
@scroll-to-top="handleScrollToTop" @scroll-to-top="handleScrollToTop"

View file

@ -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>

View file

@ -3,97 +3,41 @@
v-if="show" v-if="show"
dialog dialog
:title="modalTitle" :title="modalTitle"
styles="sm:max-w-2xl" :styles="mode === 'view' ? 'sm:max-w-6xl' : 'sm:max-w-2xl'"
@close="hideModal" @close="hideModal"
> >
<template #body> <template #body>
<div class="flex flex-col space-y-6"> <CollectionsDocumentationPublishDocSnapshotPreview
<!-- Title Input --> v-if="mode === 'view'"
<div> :existing-data="existingData"
<HoppSmartInput :published-url="publishedUrl"
v-model="publishTitle" :show="show && mode === 'view'"
label="Title" @copy-url="copyUrl"
type="text" @view-published="viewPublished"
:readonly="mode === 'view'" />
:class="{ 'opacity-60 cursor-not-allowed': mode === 'view' }"
input-styles="floating-input"
/>
</div>
<!-- Additional Fields (will be enabled in the future) --> <!-- Create/Update mode: Form -->
<!-- Version Input --> <CollectionsDocumentationPublishDocForm
<!-- <div> v-else
<label class="block text-sm font-medium text-secondaryDark mb-2"> v-model:publish-title="publishTitle"
{{ t("documentation.publish.doc_version") }} v-model:publish-version="publishVersion"
</label> v-model:auto-sync="autoSync"
<input v-model:selected-environment-i-d="selectedEnvironmentID"
v-model="publishVersion" :published-url="publishedUrl"
type="text" :is-first-publish="isFirstPublish ?? false"
:readonly="mode === 'view'" :is-auto-sync-locked="isAutoSyncLocked ?? false"
class="w-full px-3 py-2 border border-divider rounded bg-primary text-secondaryDark focus:outline-none focus:border-accent" :is-valid-version="isValidVersion"
:class="{ 'opacity-60 cursor-not-allowed': mode === 'view' }" :workspace-type="workspaceType"
placeholder="1.0.0" :workspace-i-d="workspaceID"
/> :mode="mode === 'update' ? 'update' : 'create'"
</div> --> @copy-url="copyUrl"
@view-published="viewPublished"
<!-- 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>
</template> </template>
<template #footer> <template #footer>
<div class="flex justify-between items-center flex-1"> <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 <HoppButtonPrimary
v-if="mode === 'create' && !publishedUrl" v-if="mode === 'create' && !publishedUrl"
:label="t('documentation.publish.button')" :label="t('documentation.publish.button')"
@ -109,18 +53,17 @@
@click="handleUpdate" @click="handleUpdate"
/> />
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.cancel')" :label="mode === 'view' ? t('action.close') : t('action.cancel')"
outline outline
filled filled
@click="hideModal" @click="hideModal"
/> />
</div> </div>
<div class="flex"> <div v-if="mode === 'update' || mode === 'view'" class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-if="mode === 'update'"
:icon="IconTrash2" :icon="IconTrash2"
:label="t('documentation.publish.unpublish')" :label="t('documentation.publish.unpublish')"
class="!text-red-500" class="!text-red-500 hover:!bg-red-500/10"
:loading="loading" :loading="loading"
:disabled="loading" :disabled="loading"
filled filled
@ -143,14 +86,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, markRaw } from "vue" import { ref, computed, watch } from "vue"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast" import { useToast } from "~/composables/toast"
import { platform } from "~/platform" 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 IconTrash2 from "~icons/lucide/trash-2"
import { import {
@ -158,7 +98,8 @@ import {
UpdatePublishedDocsArgs, UpdatePublishedDocsArgs,
WorkspaceType, WorkspaceType,
} from "~/helpers/backend/graphql" } 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 t = useI18n()
const toast = useToast() const toast = useToast()
@ -170,12 +111,16 @@ const props = defineProps<{
workspaceType: WorkspaceType workspaceType: WorkspaceType
workspaceID: string workspaceID: string
mode?: "create" | "update" | "view" mode?: "create" | "update" | "view"
isFirstPublish?: boolean
isAutoSyncLocked?: boolean
publishedDocId?: string publishedDocId?: string
existingData?: { existingData?: {
title: string title: string
version: string version: string
autoSync: boolean autoSync: boolean
url: string url: string
environmentName?: string | null
environmentID?: string | null
} }
loading?: boolean loading?: boolean
}>() }>()
@ -189,11 +134,13 @@ const emit = defineEmits<{
}>() }>()
const publishTitle = ref(props.existingData?.title || props.collectionTitle) const publishTitle = ref(props.existingData?.title || props.collectionTitle)
const publishVersion = ref(props.existingData?.version || "latest") const publishVersion = ref(props.existingData?.version || CURRENT_VERSION_TAG)
const autoSync = ref(props.existingData?.autoSync ?? true) const autoSync = ref(props.existingData?.autoSync ?? false)
const publishedUrl = ref<string | null>(props.existingData?.url || null) 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 { copy } = useClipboard()
const showDeleteConfirmModal = ref(false) const showDeleteConfirmModal = ref(false)
@ -205,15 +152,23 @@ const initializeFormData = () => {
publishVersion.value = props.existingData.version publishVersion.value = props.existingData.version
autoSync.value = props.existingData.autoSync autoSync.value = props.existingData.autoSync
publishedUrl.value = props.existingData.url publishedUrl.value = props.existingData.url
} else { selectedEnvironmentID.value = props.existingData.environmentID ?? null
} else if (props.isFirstPublish) {
publishTitle.value = props.collectionTitle publishTitle.value = props.collectionTitle
publishVersion.value = "1.0.0" publishVersion.value = CURRENT_VERSION_TAG
autoSync.value = true autoSync.value = true
publishedUrl.value = null 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( watch(
[() => props.existingData, () => props.show], [() => props.existingData, () => props.show],
([, isOpen]) => { ([, isOpen]) => {
@ -231,7 +186,13 @@ const modalTitle = computed(() => {
}) })
const canPublish = 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(() => { const hasChanges = computed(() => {
@ -240,7 +201,8 @@ const hasChanges = computed(() => {
return ( return (
publishTitle.value.trim() !== props.existingData.title || publishTitle.value.trim() !== props.existingData.title ||
publishVersion.value.trim() !== props.existingData.version || 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, workspaceID: props.workspaceID,
collectionID: props.collectionID, collectionID: props.collectionID,
metadata: "{}", metadata: "{}",
environmentID: selectedEnvironmentID.value || undefined,
} }
emit("publish", doc) emit("publish", doc)
@ -272,6 +235,7 @@ const handleUpdate = () => {
version: publishVersion.value.trim(), version: publishVersion.value.trim(),
autoSync: autoSync.value, autoSync: autoSync.value,
metadata: "{}", metadata: "{}",
environmentID: selectedEnvironmentID.value ?? null,
} }
emit("update", props.publishedDocId, doc) emit("update", props.publishedDocId, doc)
@ -279,7 +243,6 @@ const handleUpdate = () => {
const copyUrl = () => { const copyUrl = () => {
if (publishedUrl.value) { if (publishedUrl.value) {
copyIcon.value = markRaw(IconCheck)
copy(publishedUrl.value) copy(publishedUrl.value)
toast.success(t("documentation.publish.url_copied")) toast.success(t("documentation.publish.url_copied"))
} }

View file

@ -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>

View file

@ -44,7 +44,6 @@
/> />
</div> </div>
<!-- Check performance issue -->
<CollectionsDocumentationSectionsCurlView <CollectionsDocumentationSectionsCurlView
:request="request" :request="request"
:collection-i-d="collectionID" :collection-i-d="collectionID"
@ -53,6 +52,7 @@
:request-index="requestIndex" :request-index="requestIndex"
:team-i-d="teamID" :team-i-d="teamID"
:inherited-properties="inheritedProperties" :inherited-properties="inheritedProperties"
:environment-variables="environmentVariables"
/> />
<CollectionsDocumentationSectionsAuth <CollectionsDocumentationSectionsAuth
@ -106,7 +106,6 @@ import { DocumentationService } from "~/services/documentation.service"
import { cascadeParentCollectionForProperties } from "~/newstore/collections" import { cascadeParentCollectionForProperties } from "~/newstore/collections"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { getEffectiveRESTRequest } from "~/helpers/utils/EffectiveURL" import { getEffectiveRESTRequest } from "~/helpers/utils/EffectiveURL"
import { computedAsync } from "@vueuse/core"
import { import {
AggregateEnvironment, AggregateEnvironment,
getCurrentEnvironment, getCurrentEnvironment,
@ -131,6 +130,7 @@ const props = withDefaults(
teamID?: string teamID?: string
readOnly?: boolean readOnly?: boolean
inheritedProperties?: HoppInheritedProperty inheritedProperties?: HoppInheritedProperty
environmentVariables?: Environment["variables"]
}>(), }>(),
{ {
documentationDescription: "", documentationDescription: "",
@ -143,6 +143,7 @@ const props = withDefaults(
teamID: undefined, teamID: undefined,
readOnly: false, readOnly: false,
inheritedProperties: undefined, inheritedProperties: undefined,
environmentVariables: () => [],
} }
) )
@ -241,6 +242,8 @@ const getEffectiveRequest = async () => {
sourceEnv: "CollectionVariables", sourceEnv: "CollectionVariables",
}) || e.initialValue, }) || e.initialValue,
})), })),
// Published doc environment variables (from attached environment)
...(props.environmentVariables || []),
], ],
} }
@ -249,6 +252,7 @@ const getEffectiveRequest = async () => {
...props.request, ...props.request,
}), }),
env, env,
true,
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() const res = await getEffectiveRequest()
if (!res) return "" if (!res) {
getFullEndpoint.value = ""
return
}
const { effectiveRequest } = res const { effectiveRequest } = res
if (!effectiveRequest) return "" if (!effectiveRequest) {
getFullEndpoint.value = ""
return
}
const input = effectiveRequest.effectiveFinalURL const input = effectiveRequest.effectiveFinalURL
if (!input) { if (!input) {
return "https://" getFullEndpoint.value = "https://"
return
} }
let url = input.trim() let url = input.trim()
@ -386,8 +399,17 @@ const getFullEndpoint = computedAsync(async () => {
url = (isLocalOrIP ? "http://" : "https://") + endpoint 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) => { const copyToClipboard = async (text: string | undefined) => {
if (!text) return if (!text) return

View file

@ -69,77 +69,144 @@
</span> </span>
<div class="flex space-x-2 items-center"> <div class="flex space-x-2 items-center">
<!-- Publish Button - Simple button when not published --> <!-- Show published documentation button only when showAllDocumentation is true -->
<HoppButtonSecondary <div v-if="showAllDocumentation">
v-if=" <!-- Publish Button - when not published -->
currentCollection && !isCollectionPublished && hasTeamWriteAccess <HoppButtonSecondary
" v-if="
:icon="IconShare2" currentCollection &&
:label="t('documentation.publish.button')" !isCollectionPublished &&
outline hasTeamWriteAccess
filled "
@click="openPublishModal" :icon="IconShare2"
/> :label="t('documentation.publish.button')"
<tippy outline
v-else-if=" filled
currentCollection && isCollectionPublished && hasTeamWriteAccess @click="openPublishModal"
" />
ref="publishedDropdown" <tippy
interactive v-else-if="
trigger="click" currentCollection && isCollectionPublished && hasTeamWriteAccess
theme="popover" "
:on-shown="() => publishedDropdownActions?.focus()" ref="publishedDropdown"
> interactive
<div trigger="click"
class="flex items-center border border-accent pl-4 pr-2 rounded cursor-pointer" 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 <div
ref="publishedDropdownActions" class="flex items-center border border-accent pl-4 pr-2 rounded cursor-pointer"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
> >
<div class="flex flex-col space-y-2"> <icon-lucide-globe class="svg-icons" />
<div class="flex items-center space-x-2">
<HoppSmartInput <HoppButtonSecondary
:model-value="existingPublishedData?.url" :icon="IconCheveronDown"
disabled reverse
class="flex-1 !min-w-60" :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 <div class="h-px bg-divider my-1"></div>
v-tippy="{ theme: 'tooltip' }" <div
:title="t('documentation.publish.copy_url')" v-if="publishedDocs.length > 0"
:icon="copyIcon" ref="publishedDocsListRef"
@click="copyPublishedUrl" 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> </div>
<HoppSmartItem
reverse
:icon="IconPenLine"
:label="t('documentation.publish.edit_published_doc')"
@click="
() => {
hide()
openPublishModal()
}
"
/>
</div> </div>
</div> </template>
</template> </tippy>
</tippy> </div>
<HoppButtonSecondary <HoppButtonSecondary
v-if="currentCollection" v-if="currentCollection"
:icon="isDocumentationProcessing ? IconLoader2 : IconFileText" :icon="isDocumentationProcessing ? IconLoader2 : IconFileText"
@ -169,6 +236,12 @@
:workspace-type="isTeamCollection ? WorkspaceType.Team : WorkspaceType.User" :workspace-type="isTeamCollection ? WorkspaceType.Team : WorkspaceType.User"
:workspace-i-d="isTeamCollection ? teamID || '' : ''" :workspace-i-d="isTeamCollection ? teamID || '' : ''"
:mode="publishModalMode" :mode="publishModalMode"
:is-first-publish="!isCollectionPublished && !isCreatingNewVersion"
:is-auto-sync-locked="
!!selectedVersionDoc &&
isLiveVersion(selectedVersionDoc) &&
!isCreatingNewVersion
"
:published-doc-id="publishedDocId" :published-doc-id="publishedDocId"
:existing-data="existingPublishedData" :existing-data="existingPublishedData"
:loading="isProcessingPublish" :loading="isProcessingPublish"
@ -207,7 +280,9 @@ import { getErrorMessage } from "~/helpers/backend/mutations/MockServer"
import { import {
DocumentationService, DocumentationService,
isLiveVersion,
type DocumentationItem, type DocumentationItem,
type PublishedDocInfo,
} from "~/services/documentation.service" } from "~/services/documentation.service"
import IconFileText from "~icons/lucide/file-text" 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 IconCheveronDown from "~icons/lucide/chevron-down"
import IconCopy from "~icons/lucide/copy" import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import IconEye from "~icons/lucide/eye"
import { import {
WorkspaceType, WorkspaceType,
@ -229,8 +306,6 @@ import {
updatePublishedDoc, updatePublishedDoc,
} from "~/helpers/backend/mutations/PublishedDocs" } from "~/helpers/backend/mutations/PublishedDocs"
import { TippyComponent } from "vue-tippy"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
@ -282,30 +357,93 @@ const allItems = ref<Array<any>>([])
const showAllDocumentation = ref<boolean>(false) const showAllDocumentation = ref<boolean>(false)
const showPublishModal = 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 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 // Published docs state
const publishedDocStatus = computed(() => { const publishedDocs = computed(() => {
if (!props.collectionID) return undefined if (!props.collectionID) return []
return documentationService.getPublishedDocStatus(props.collectionID) return documentationService.getPublishedDocStatus(props.collectionID) || []
}) })
const isCollectionPublished = computed(() => !!publishedDocStatus.value) const selectedVersionDoc = ref<PublishedDocInfo | null>(null)
const publishedDocId = computed(() => publishedDocStatus.value?.id)
/**
* 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(() => { const existingPublishedData = computed(() => {
if (!publishedDocStatus.value) return undefined if (isCreatingNewVersion.value) return undefined
if (!selectedVersionDoc.value) return undefined
return { return {
title: publishedDocStatus.value.title, title: selectedVersionDoc.value.title,
version: publishedDocStatus.value.version, version: selectedVersionDoc.value.version,
autoSync: publishedDocStatus.value.autoSync, autoSync: selectedVersionDoc.value.autoSync,
url: publishedDocStatus.value.url, url: selectedVersionDoc.value.url,
environmentName: selectedVersionDoc.value.environmentName ?? null,
environmentID: selectedVersionDoc.value.environmentID ?? null,
} }
}) })
const isViewingSnapshot = ref(false)
const publishModalMode = computed<"create" | "update" | "view">(() => { 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(() => { const isDocumentationProcessing = computed(() => {
@ -408,10 +546,8 @@ const handleToggleAllDocumentation = async () => {
// Reset fetched collection data when modal opens/closes // Reset fetched collection data when modal opens/closes
watch( watch(
() => props.show, () => props.show,
async (newVal) => { (newVal) => {
if (newVal) { if (!newVal) {
// No need to manually check published docs status as it is now reactive
} else {
// Reset when modal closes // Reset when modal closes
fullCollectionData.value = null fullCollectionData.value = null
isLoadingTeamCollection.value = false isLoadingTeamCollection.value = false
@ -449,9 +585,51 @@ watch(
) )
const openPublishModal = () => { const openPublishModal = () => {
isViewingSnapshot.value = false
showPublishModal.value = true 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 = () => { const copyPublishedUrl = () => {
if (existingPublishedData.value?.url) { if (existingPublishedData.value?.url) {
copyIcon.value = markRaw(IconCheck) copyIcon.value = markRaw(IconCheck)
@ -785,8 +963,18 @@ const hideModal = () => {
if (closeTimeout) clearTimeout(closeTimeout) if (closeTimeout) clearTimeout(closeTimeout)
} }
const handlePublish = async (doc: CreatePublishedDocsArgs) => { const handlePublish = async (
doc: CreatePublishedDocsArgs,
environmentVariables?: string
) => {
isProcessingPublish.value = true isProcessingPublish.value = true
if (environmentVariables) {
const metadata = JSON.parse(doc.metadata || "{}")
metadata.environmentVariables = environmentVariables
doc.metadata = JSON.stringify(metadata)
}
await pipe( await pipe(
createPublishedDoc(doc), createPublishedDoc(doc),
TE.match( TE.match(
@ -804,6 +992,13 @@ const handlePublish = async (doc: CreatePublishedDocsArgs) => {
version: doc.version, version: doc.version,
autoSync: doc.autoSync, autoSync: doc.autoSync,
url: url, 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 // Update service
@ -813,14 +1008,29 @@ const handlePublish = async (doc: CreatePublishedDocsArgs) => {
newDocInfo newDocInfo
) )
} }
// Select the new version and exit create mode
selectedVersionDoc.value = newDocInfo
isCreatingNewVersion.value = false
} }
) )
)() )()
isProcessingPublish.value = false isProcessingPublish.value = false
} }
const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => { const handleUpdate = async (
id: string,
doc: UpdatePublishedDocsArgs,
environmentVariables?: string
) => {
isProcessingPublish.value = true isProcessingPublish.value = true
if (environmentVariables) {
const metadata = JSON.parse(doc.metadata || "{}")
metadata.environmentVariables = environmentVariables
doc.metadata = JSON.stringify(metadata)
}
await pipe( await pipe(
updatePublishedDoc(id, doc), updatePublishedDoc(id, doc),
TE.match( TE.match(
@ -839,6 +1049,13 @@ const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => {
version: data.updatePublishedDoc.version, version: data.updatePublishedDoc.version,
autoSync: data.updatePublishedDoc.autoSync, autoSync: data.updatePublishedDoc.autoSync,
url: url, 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 // Update service
@ -873,7 +1090,11 @@ const handleDelete = async () => {
// Update service // Update service
if (props.collectionID) { if (props.collectionID) {
documentationService.setPublishedDocStatus(props.collectionID, null) documentationService.setPublishedDocStatus(
props.collectionID,
null,
publishedDocId.value
)
} }
} }
) )

View file

@ -209,6 +209,7 @@ const props = withDefaults(
requestIndex?: number | null requestIndex?: number | null
teamID?: string teamID?: string
inheritedProperties?: HoppInheritedProperty inheritedProperties?: HoppInheritedProperty
environmentVariables?: Environment["variables"]
}>(), }>(),
{ {
request: null, request: null,
@ -217,6 +218,7 @@ const props = withDefaults(
folderPath: null, folderPath: null,
requestIndex: null, requestIndex: null,
teamID: undefined, teamID: undefined,
environmentVariables: () => [],
} }
) )
@ -336,6 +338,8 @@ const getEffectiveRequest = async () => {
sourceEnv: "Global", sourceEnv: "Global",
} as AggregateEnvironment) || envVar.initialValue, } as AggregateEnvironment) || envVar.initialValue,
})), })),
// Published doc environment variables (from attached environment)
...(props.environmentVariables || []),
], ],
} }
@ -344,6 +348,7 @@ const getEffectiveRequest = async () => {
...props.request, ...props.request,
}), }),
env, env,
true,
true true
) )
@ -352,6 +357,17 @@ const getEffectiveRequest = async () => {
return result 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 // Lazy computed for cURL command
const curlCommand = ref<string>("") const curlCommand = ref<string>("")

View file

@ -1,10 +1,16 @@
<template> <template>
<main class="flex-1 flex overflow-hidden"> <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 <CollectionsDocumentationCollectionStructure
v-if="collectionData" v-if="collectionData"
:collection="collectionData" :collection="collectionData"
:is-doc-modal="false" :compact="compact"
:is-doc-modal="isDocModal"
@request-select="handleRequestSelect" @request-select="handleRequestSelect"
@folder-select="handleFolderSelect" @folder-select="handleFolderSelect"
@scroll-to-top="handleScrollToTop" @scroll-to-top="handleScrollToTop"
@ -52,6 +58,7 @@
:collection-i-d="collectionData.id" :collection-i-d="collectionData.id"
:inherited-properties="getInheritedProperties(item)" :inherited-properties="getInheritedProperties(item)"
:read-only="true" :read-only="true"
:environment-variables="environmentVariables"
/> />
</div> </div>
</div> </div>
@ -62,7 +69,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PropType, ref, onMounted } from "vue" 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 { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { useRouter, useRoute } from "vue-router" import { useRouter, useRoute } from "vue-router"
@ -86,6 +93,18 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
compact: {
type: Boolean,
default: false,
},
environmentVariables: {
type: Array as PropType<Environment["variables"]>,
default: () => [],
},
isDocModal: {
type: Boolean,
default: false,
},
}) })
const router = useRouter() const router = useRouter()
@ -123,14 +142,29 @@ const handleFolderSelect = (folder: HoppCollection) => {
/** /**
* Scrolls to a specific item by its ID * 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 => { const scrollToItem = (id: string): void => {
setTimeout(() => { 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) { 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", behavior: "smooth",
block: "start",
}) })
} else { } else {
console.error("Item not found:", id) console.error("Item not found:", id)

View file

@ -13,15 +13,138 @@
<span <span
class="text-md font-bold text-secondaryDark px-6 py-1 rounded-full border border-dividerDark shadow" 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> </span>
<!-- TODO: Add version (will be added in next iteration) -->
<!-- <span <div>
v-if="publishedDoc?.version" <!-- Version dropdown (when multiple versions exist) -->
class="px-2 py-0.5 text-xs font-medium rounded-md bg-accent/10 text-accent" <tippy
> v-if="versions.length"
{{ publishedDoc.version }} interactive
</span> --> 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> </div>
</div> </div>
@ -29,10 +152,27 @@
</template> </template>
<script setup lang="ts"> <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 { 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: { publishedDoc: {
type: Object as PropType<Partial<PublishedDocs> | null>, type: Object as PropType<Partial<PublishedDocs> | null>,
default: null, default: null,
@ -41,5 +181,57 @@ defineProps({
type: String, type: String,
default: "Hoppscotch", 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> </script>

View file

@ -5,6 +5,7 @@ mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) {
version version
autoSync autoSync
url url
environmentName
createdOn createdOn
updatedOn updatedOn
workspaceType workspaceType

View file

@ -5,6 +5,7 @@ mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) {
version version
autoSync autoSync
url url
environmentName
createdOn createdOn
updatedOn updatedOn
} }

View file

@ -6,6 +6,8 @@ query PublishedDoc($id: ID!) {
autoSync autoSync
url url
metadata metadata
environmentName
environmentVariables
createdOn createdOn
updatedOn updatedOn
creator { creator {

View file

@ -15,6 +15,8 @@ query TeamPublishedDocsList(
version version
autoSync autoSync
url url
documentTree
environmentName
collection { collection {
id id
} }

View file

@ -5,6 +5,8 @@ query UserPublishedDocsList($skip: Int!, $take: Int!) {
version version
autoSync autoSync
url url
documentTree
environmentName
collection { collection {
id id
} }

View file

@ -27,6 +27,7 @@ export type PublishedDocListItem = {
version: string version: string
autoSync: boolean autoSync: boolean
url: string url: string
environmentName?: string | null
collection: { collection: {
id: string id: string
} }
@ -47,6 +48,7 @@ export type PublishedDoc = PublishedDocListItem & {
id: string id: string
title: string title: string
} }
versions?: PublishedDocListItem[]
} }
// Type for the GraphQL query response // Type for the GraphQL query response
@ -63,6 +65,27 @@ export type CollectionFolder = {
data?: string 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 * Parses the data field (stringified JSON) to extract auth, headers, variables, and description
* @param data The stringified JSON data from CollectionFolder * @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 slug - The slug 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 * @param version - The version of the published doc to fetch
* @returns The published doc with the specified ID * @returns The published doc with the specified slug
*/ */
export const getPublishedDocByIDREST = ( export const getPublishedDocBySlugREST = (
id: string slug: string,
//tree: "FULL" | "MINIMAL" = "FULL" version?: string
): TE.TaskEither<GetPublishedDocError, PublishedDocs> => ): TE.TaskEither<GetPublishedDocError, PublishedDocs> =>
TE.tryCatch( TE.tryCatch(
async () => { async () => {
const backendUrl = import.meta.env.VITE_BACKEND_API_URL || "" 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) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)

View file

@ -355,13 +355,15 @@ export function getFinalBodyFromRequest(
* @param request The request to source from * @param request The request to source from
* @param environment The environment to apply * @param environment The environment to apply
* @param showKeyIfSecret Whether to show the key if the value is a secret * @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 * @returns An object with extra fields defining a complete request
*/ */
export async function getEffectiveRESTRequest( export async function getEffectiveRESTRequest(
request: HoppRESTRequest, request: HoppRESTRequest,
environment: Environment, environment: Environment,
showKeyIfSecret = false showKeyIfSecret = false,
showKeyIfNotFound = false
): Promise<EffectiveHoppRESTRequest> { ): Promise<EffectiveHoppRESTRequest> {
const effectiveFinalHeaders = pipe( const effectiveFinalHeaders = pipe(
( (
@ -433,7 +435,8 @@ export async function getEffectiveRESTRequest(
request.endpoint, request.endpoint,
environment.variables, environment.variables,
false, false,
showKeyIfSecret showKeyIfSecret,
showKeyIfNotFound
), ),
effectiveFinalHeaders, effectiveFinalHeaders,
effectiveFinalParams, effectiveFinalParams,

View file

@ -3,7 +3,11 @@
<DocumentationHeader <DocumentationHeader
v-if="!loading && !error && publishedDoc" v-if="!loading && !error && publishedDoc"
:published-doc="publishedDoc" :published-doc="publishedDoc"
:versions="availableVersions"
:instance-display-name="instanceDisplayName" :instance-display-name="instanceDisplayName"
:environment-name="environmentName"
:environment-enabled="environmentEnabled"
@toggle-environment="handleEnvironmentToggle"
/> />
<DocumentationSkeleton v-if="loading" /> <DocumentationSkeleton v-if="loading" />
@ -24,25 +28,36 @@
<DocumentationContent <DocumentationContent
v-else-if="collectionData" v-else-if="collectionData"
:collection-data="collectionData" :collection-data="collectionData"
:is-doc-modal="false"
:all-items="allItems" :all-items="allItems"
:update-url-on-select="true" :update-url-on-select="true"
:environment-variables="environmentVariables"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue" import { ref, onMounted, computed, watch } from "vue"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { import {
getPublishedDocByIDREST, getPublishedDocBySlugREST,
collectionFolderToHoppCollection, collectionFolderToHoppCollection,
} from "~/helpers/backend/queries/PublishedDocs" } from "~/helpers/backend/queries/PublishedDocs"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import IconAlertCircle from "~icons/lucide/alert-circle" 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 { 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 { getKernelMode } from "@hoppscotch/kernel"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream" import { useReadonlyStream } from "~/composables/stream"
@ -77,11 +92,18 @@ const instanceDisplayName = computed(() => {
return currentState.value.instance.displayName 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 collectionData = ref<HoppCollection | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) 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 = { type DocumentationItem = {
id: string id: string
type: "folder" | "request" type: "folder" | "request"
@ -164,47 +186,105 @@ const allItems = computed<DocumentationItem[]>(() => {
return flattenCollection(collectionData.value) return flattenCollection(collectionData.value)
}) })
onMounted(async () => { const fetchDocs = async (docId: string, version: string) => {
const docId = route.params.id as string loading.value = true
// will use in next iteration error.value = null
//const version = route.params.version as string
if (!docId) { if (!docId) {
error.value = "No document ID provided" error.value = t("documentation.publish.no_doc_id")
loading.value = false loading.value = false
return return
} }
// Fetch published doc using REST API (public access, no authentication required) // 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)) { if (E.isLeft(result)) {
console.error("Error fetching published doc:", result.left) console.error("Error fetching published doc:", result.left)
error.value = "Published documentation not found" error.value = t("documentation.publish.not_found")
loading.value = false loading.value = false
return return
} }
publishedDoc.value = { publishedDoc.value = {
autoSync: false, autoSync: result.right.autoSync ?? false,
createdOn: result.right.createdOn, createdOn: result.right.createdOn,
id: result.right.id, id: result.right.id,
updatedOn: result.right.updatedOn, updatedOn: result.right.updatedOn,
version: result.right.version, version: result.right.version,
metadata: result.right.metadata, metadata: result.right.metadata,
title: result.right.title, 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) const publishedData = JSON.parse(result.right.documentTree)
// Convert the REST API response (CollectionFolder) to HoppCollection format // Convert the REST API response (CollectionFolder) to HoppCollection format
const hoppCollection = collectionFolderToHoppCollection(publishedData) const hoppCollection = collectionFolderToHoppCollection(publishedData)
collectionData.value = hoppCollection collectionData.value = hoppCollection
loading.value = false 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({ usePageHead({
title: computed( title: computed(
() => publishedDoc.value?.title || "Hoppscotch Documentation" () => publishedDoc.value?.title || "Hoppscotch Documentation"

View file

@ -13,6 +13,8 @@ import {
RequestDocumentationItem, RequestDocumentationItem,
SetCollectionDocumentationOptions, SetCollectionDocumentationOptions,
SetRequestDocumentationOptions, SetRequestDocumentationOptions,
isLiveVersion,
CURRENT_VERSION_TAG,
} from "../documentation.service" } from "../documentation.service"
import { import {
getUserPublishedDocs, getUserPublishedDocs,
@ -467,11 +469,13 @@ describe("DocumentationService", () => {
const mockDocs = [ const mockDocs = [
{ {
id: "doc-1", id: "doc-1",
collection: { id: "col-1" },
title: "Doc 1", title: "Doc 1",
version: "v1", version: "v1",
autoSync: true, 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() await service.fetchUserPublishedDocs()
const status = service.getPublishedDocStatus("col-1") const status = service.getPublishedDocStatus("coll-1")
expect(status).toEqual({ expect(status).toEqual([
id: "doc-1", {
title: "Doc 1", id: "doc-1",
version: "v1", title: "Doc 1",
autoSync: true, version: "v1",
url: "url-1", 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 () => { it("should fetch team published docs and update map", async () => {
const mockDocs = [ const mockDocs = [
{ {
id: "doc-2", id: "doc-2",
collection: { id: "col-2" },
title: "Doc 2", title: "Doc 2",
version: "v2", version: "v2",
autoSync: false, autoSync: false,
url: "url-2", 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") await service.fetchTeamPublishedDocs("team-1")
const status = service.getPublishedDocStatus("col-2") const status = service.getPublishedDocStatus("col-2")
expect(status).toEqual({
id: "doc-2", expect(status).toEqual([
title: "Doc 2", {
version: "v2", id: "doc-2",
autoSync: false, title: "Doc 2",
url: "url-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 () => { it("should handle error when fetching user published docs", async () => {
@ -556,11 +573,14 @@ describe("DocumentationService", () => {
version: "v3", version: "v3",
autoSync: true, autoSync: true,
url: "url-3", url: "url-3",
collection: { id: "col-3" },
createdOn: "2023-01-03",
updatedOn: "2023-01-03",
} }
service.setPublishedDocStatus("col-3", info) 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", () => { it("should remove published doc status", () => {
@ -570,6 +590,9 @@ describe("DocumentationService", () => {
version: "v3", version: "v3",
autoSync: true, autoSync: true,
url: "url-3", url: "url-3",
collection: { id: "col-3" },
createdOn: "2023-01-03",
updatedOn: "2023-01-03",
} }
service.setPublishedDocStatus("col-3", info) service.setPublishedDocStatus("col-3", info)
@ -583,22 +606,26 @@ describe("DocumentationService", () => {
const slowDocs = [ const slowDocs = [
{ {
id: "doc-slow", id: "doc-slow",
collection: { id: "col-1" },
title: "Slow Doc", title: "Slow Doc",
version: "v1", version: "v1",
autoSync: true, autoSync: true,
url: "url-slow", url: "url-slow",
collection: { id: "col-1" },
createdOn: "2023-01-01",
updatedOn: "2023-01-01",
}, },
] ]
const fastDocs = [ const fastDocs = [
{ {
id: "doc-fast", id: "doc-fast",
collection: { id: "col-1" },
title: "Fast Doc", title: "Fast Doc",
version: "v2", version: "v2",
autoSync: true, autoSync: true,
url: "url-fast", url: "url-fast",
collection: { id: "col-1" },
createdOn: "2023-01-02",
updatedOn: "2023-01-02",
}, },
] ]
@ -622,26 +649,132 @@ describe("DocumentationService", () => {
await secondCall await secondCall
// Verify the fast response is applied // Verify the fast response is applied
expect(service.getPublishedDocStatus("col-1")).toEqual({ expect(service.getPublishedDocStatus("col-1")).toEqual([
id: "doc-fast", {
title: "Fast Doc", id: "doc-fast",
version: "v2", title: "Fast Doc",
autoSync: true, version: "v2",
url: "url-fast", autoSync: true,
}) url: "url-fast",
collection: { id: "col-1" },
createdOn: "2023-01-02",
updatedOn: "2023-01-02",
},
])
// Now resolve the slow request // Now resolve the slow request
resolveSlow!(E.right(slowDocs as any)) resolveSlow!(E.right(slowDocs as any))
await firstCall await firstCall
// Verify the state hasn't changed (slow response ignored) // Verify the state hasn't changed (slow response ignored)
expect(service.getPublishedDocStatus("col-1")).toEqual({ expect(service.getPublishedDocStatus("col-1")).toEqual([
id: "doc-fast", {
title: "Fast Doc", 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", version: "v2",
autoSync: true, 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)
})
})

View file

@ -17,6 +17,13 @@ export interface PublishedDocInfo {
version: string version: string
autoSync: boolean autoSync: boolean
url: string 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 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. * This service manages edited documentation for collections and requests.
* It temporarily stores the edited documentation in a map for efficient saving. * 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 * 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 * Counter to track the latest fetch request ID
@ -265,16 +291,23 @@ export class DocumentationService extends Service {
if (E.isRight(result)) { if (E.isRight(result)) {
const docs = result.right const docs = result.right
const newMap = new Map<string, PublishedDocInfo>() const newMap = new Map<string, PublishedDocInfo[]>()
docs.forEach((doc) => { docs.forEach((doc) => {
if (doc.collection?.id) { if (doc.collection?.id) {
newMap.set(doc.collection.id, { const existing = newMap.get(doc.collection.id) || []
existing.push({
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
version: doc.version, version: doc.version,
autoSync: doc.autoSync, autoSync: doc.autoSync,
url: doc.url, url: doc.url,
collection: {
id: doc.collection.id,
},
createdOn: doc.createdOn,
updatedOn: doc.updatedOn,
}) })
newMap.set(doc.collection.id, existing)
} }
}) })
this.publishedDocsMap.value = newMap this.publishedDocsMap.value = newMap
@ -304,16 +337,23 @@ export class DocumentationService extends Service {
if (E.isRight(result)) { if (E.isRight(result)) {
const docs = result.right const docs = result.right
const newMap = new Map<string, PublishedDocInfo>() const newMap = new Map<string, PublishedDocInfo[]>()
docs.forEach((doc) => { docs.forEach((doc) => {
if (doc.collection?.id) { if (doc.collection?.id) {
newMap.set(doc.collection.id, { const existing = newMap.get(doc.collection.id) || []
existing.push({
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
version: doc.version, version: doc.version,
autoSync: doc.autoSync, autoSync: doc.autoSync,
url: doc.url, url: doc.url,
collection: {
id: doc.collection.id,
},
createdOn: doc.createdOn,
updatedOn: doc.updatedOn,
}) })
newMap.set(doc.collection.id, existing)
} }
}) })
this.publishedDocsMap.value = newMap 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 * @param collectionId The ID of the collection
*/ */
public getPublishedDocStatus( public getPublishedDocStatus(
collectionId: string collectionId: string
): PublishedDocInfo | undefined { ): PublishedDocInfo[] | undefined {
return this.publishedDocsMap.value.get(collectionId) 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 * Manually updates the published status of a collection
* @param collectionId The ID of the 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( public setPublishedDocStatus(
collectionId: string, 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) const newMap = new Map(this.publishedDocsMap.value)
if (info) { const existing = newMap.get(collectionId) || []
newMap.set(collectionId, info)
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 { } else {
// Remove all if info is null and no removeId
newMap.delete(collectionId) newMap.delete(collectionId)
} }
this.publishedDocsMap.value = newMap this.publishedDocsMap.value = newMap

View file

@ -104,7 +104,8 @@ export function parseTemplateStringE(
str: string, str: string,
variables: Environment["variables"], variables: Environment["variables"],
maskValue = false, maskValue = false,
showKeyIfSecret = false showKeyIfSecret = false,
showKeyIfNotFound = false
) { ) {
if (!variables || !str) { if (!variables || !str) {
return E.right(str) return E.right(str)
@ -119,42 +120,55 @@ export function parseTemplateStringE(
depth <= ENV_MAX_EXPAND_LIMIT && depth <= ENV_MAX_EXPAND_LIMIT &&
!isSecret !isSecret
) { ) {
result = decodeURI(encodeURI(result)).replace(REGEX_ENV_VAR, (_, p1) => { const currentResult = decodeURI(encodeURI(result)).replace(
// Prioritise predefined variable values over normal environment variables processing. REGEX_ENV_VAR,
const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find( (_, p1) => {
(preVar) => preVar.key === p1 // Prioritise predefined variable values over normal environment variables processing.
) const foundPredefinedVar = HOPP_SUPPORTED_PREDEFINED_VARIABLES.find(
(preVar) => preVar.key === p1
)
if (foundPredefinedVar) { if (foundPredefinedVar) {
return foundPredefinedVar.getValue() 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) { if (variable && "currentValue" in variable) {
// Show the key if it is a secret and explicitly specified // Show the key if it is a secret and explicitly specified
if (variable.secret && showKeyIfSecret) { if (variable.secret && showKeyIfSecret) {
isSecret = true 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}>>` 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++ depth++
} }
@ -179,10 +193,17 @@ export const parseTemplateString = (
str: string, str: string,
variables: Environment["variables"], variables: Environment["variables"],
maskValue = false, maskValue = false,
showKeyIfSecret = false showKeyIfSecret = false,
showKeyIfNotFound = false
) => ) =>
pipe( pipe(
parseTemplateStringE(str, variables, maskValue, showKeyIfSecret), parseTemplateStringE(
str,
variables,
maskValue,
showKeyIfSecret,
showKeyIfNotFound
),
E.getOrElse(() => str) E.getOrElse(() => str)
) )