feat: api documentation versioning (#5676)
Co-authored-by: nivedin <nivedinp@gmail.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
faf2bfc8eb
commit
803e4633a2
37 changed files with 3446 additions and 485 deletions
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- Step 1: Add slug column as nullable first
|
||||||
|
ALTER TABLE "PublishedDocs" ADD COLUMN "slug" TEXT;
|
||||||
|
|
||||||
|
-- Step 2: For backward compatibility, set slug = id for existing records
|
||||||
|
UPDATE "PublishedDocs" SET "slug" = "id" WHERE "slug" IS NULL;
|
||||||
|
|
||||||
|
-- Step 3: Handle duplicates - for multiple published docs with same collection and version
|
||||||
|
-- Keep the latest one (most recent), delete all older ones
|
||||||
|
-- delete old duplicates are safe, as multiple published docs with same collection and version is not expected behavior till v2025.11.x
|
||||||
|
WITH ranked_docs AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
"collectionID",
|
||||||
|
version,
|
||||||
|
"createdOn",
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY "collectionID", version ORDER BY "createdOn" DESC) as rn
|
||||||
|
FROM "PublishedDocs"
|
||||||
|
)
|
||||||
|
DELETE FROM "PublishedDocs"
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM ranked_docs WHERE rn > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Step 4: Now make slug NOT NULL
|
||||||
|
ALTER TABLE "PublishedDocs" ALTER COLUMN "slug" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PublishedDocs_collectionID_idx" ON "PublishedDocs"("collectionID");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PublishedDocs_slug_version_key" ON "PublishedDocs"("slug", "version");
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PublishedDocs" ADD COLUMN "environmentID" TEXT,
|
||||||
|
ADD COLUMN "environmentName" TEXT,
|
||||||
|
ADD COLUMN "environmentVariables" JSONB;
|
||||||
|
|
@ -297,18 +297,25 @@ model MockServerActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
model PublishedDocs {
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { IsEnum, IsOptional } from 'class-validator';
|
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export enum TreeLevel {
|
|
||||||
FULL = 'full',
|
|
||||||
FIRST_LEVEL = 'first_level',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetPublishedDocsQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Specifies whether to return full tree or only first level',
|
|
||||||
enum: TreeLevel,
|
|
||||||
default: TreeLevel.FULL,
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(TreeLevel)
|
|
||||||
tree?: TreeLevel = TreeLevel.FULL;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,69 @@
|
||||||
import { ObjectType, Field, ID } from '@nestjs/graphql';
|
import { 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()
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
11
packages/hoppscotch-common/src/components.d.ts
vendored
11
packages/hoppscotch-common/src/components.d.ts
vendored
|
|
@ -56,11 +56,14 @@ declare module 'vue' {
|
||||||
CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default']
|
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']
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tippy
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => pickerActions?.focus()"
|
||||||
|
>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:icon="IconLayers"
|
||||||
|
:label="selectedLabel"
|
||||||
|
class="flex-1 !justify-start pr-8"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="pickerActions"
|
||||||
|
role="menu"
|
||||||
|
class="flex flex-col space-y-2 focus:outline-none"
|
||||||
|
tabindex="0"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<SmartEnvInput
|
||||||
|
v-model="filterText"
|
||||||
|
:placeholder="`${t('action.search')}`"
|
||||||
|
:context-menu-enabled="false"
|
||||||
|
class="border border-dividerDark focus:border-primaryDark rounded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- No environment option -->
|
||||||
|
<HoppSmartItem
|
||||||
|
:label="t('documentation.publish.no_environment')"
|
||||||
|
:info-icon="!modelValue ? IconCheck : undefined"
|
||||||
|
:active-info-icon="!modelValue"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
$emit('update:modelValue', null)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex flex-col items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<HoppSmartSpinner class="my-2" />
|
||||||
|
<span class="text-secondaryLight text-xs">
|
||||||
|
{{ t("state.loading") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
<HoppSmartItem
|
||||||
|
v-for="env in filteredEnvironments"
|
||||||
|
:key="env.id"
|
||||||
|
:icon="IconLayers"
|
||||||
|
:label="env.name"
|
||||||
|
:info-icon="modelValue === env.id ? IconCheck : undefined"
|
||||||
|
:active-info-icon="modelValue === env.id"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
$emit('update:modelValue', env.id)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<HoppSmartPlaceholder
|
||||||
|
v-if="filteredEnvironments.length === 0 && !isLoading"
|
||||||
|
class="break-words"
|
||||||
|
:alt="
|
||||||
|
filterText
|
||||||
|
? `${t('empty.search_environment')}`
|
||||||
|
: t('empty.environments')
|
||||||
|
"
|
||||||
|
:text="
|
||||||
|
filterText
|
||||||
|
? `${t('empty.search_environment')} '${filterText}'`
|
||||||
|
: t('empty.environments')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template v-if="filterText" #icon>
|
||||||
|
<icon-lucide-search class="svg-icons opacity-75" />
|
||||||
|
</template>
|
||||||
|
</HoppSmartPlaceholder>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from "vue"
|
||||||
|
import { Environment } from "@hoppscotch/data"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { useReadonlyStream } from "~/composables/stream"
|
||||||
|
import { environments$ } from "~/newstore/environments"
|
||||||
|
import { WorkspaceType } from "~/helpers/backend/graphql"
|
||||||
|
import { runGQLQuery } from "~/helpers/backend/GQLClient"
|
||||||
|
import { GetTeamEnvironmentsDocument } from "~/helpers/backend/graphql"
|
||||||
|
import * as E from "fp-ts/Either"
|
||||||
|
import IconCheck from "~icons/lucide/check"
|
||||||
|
import IconLayers from "~icons/lucide/layers"
|
||||||
|
|
||||||
|
type EnvironmentOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string | null
|
||||||
|
workspaceType: WorkspaceType
|
||||||
|
workspaceID: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const filterText = ref("")
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const availableEnvironments = ref<EnvironmentOption[]>([])
|
||||||
|
|
||||||
|
const personalEnvironments = useReadonlyStream(environments$, [])
|
||||||
|
|
||||||
|
const pickerActions = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const selectedLabel = computed(() => {
|
||||||
|
if (!props.modelValue) return t("documentation.publish.no_environment")
|
||||||
|
const env = availableEnvironments.value.find((e) => e.id === props.modelValue)
|
||||||
|
return env?.name ?? t("documentation.publish.no_environment")
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredEnvironments = computed(() => {
|
||||||
|
if (!filterText.value) return availableEnvironments.value
|
||||||
|
|
||||||
|
const trimmed = filterText.value.trim().toLowerCase()
|
||||||
|
if (!trimmed) return []
|
||||||
|
|
||||||
|
return availableEnvironments.value.filter((env) =>
|
||||||
|
env.name.toLowerCase().includes(trimmed)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchEnvironments = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
availableEnvironments.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (props.workspaceType === WorkspaceType.Team && props.workspaceID) {
|
||||||
|
const result = await runGQLQuery({
|
||||||
|
query: GetTeamEnvironmentsDocument,
|
||||||
|
variables: { teamID: props.workspaceID },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (E.isRight(result)) {
|
||||||
|
const teamEnvs = (result.right as any).team?.teamEnvironments || []
|
||||||
|
availableEnvironments.value = teamEnvs.map(
|
||||||
|
(env: { id: string; name: string }) => ({
|
||||||
|
id: env.id,
|
||||||
|
name: env.name,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
availableEnvironments.value = personalEnvironments.value
|
||||||
|
.filter((env: Environment) => env.name)
|
||||||
|
.map((env: Environment, index: number) => ({
|
||||||
|
id: env.id || `personal-${index}`,
|
||||||
|
name: env.name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching environments:", error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch environments when workspace props change or environment list changes
|
||||||
|
watch(
|
||||||
|
[() => props.workspaceType, () => props.workspaceID, personalEnvironments],
|
||||||
|
() => {
|
||||||
|
fetchEnvironments()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
@ -158,6 +158,7 @@
|
||||||
<div v-if="showAllDocumentation" class="p-4 sticky top-0">
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col space-y-6">
|
||||||
|
<div>
|
||||||
|
<HoppSmartInput
|
||||||
|
v-model="titleModel"
|
||||||
|
:label="t('documentation.publish.doc_title')"
|
||||||
|
type="text"
|
||||||
|
input-styles="floating-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isFirstPublish"
|
||||||
|
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-green-500/5 border border-green-500/15"
|
||||||
|
>
|
||||||
|
<icon-lucide-info
|
||||||
|
class="w-3.5 h-3.5 text-green-600 flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-green-600 leading-relaxed">
|
||||||
|
{{ t("documentation.publish.first_publish_hint") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version Input (hidden for first publish) -->
|
||||||
|
<div v-if="!isFirstPublish">
|
||||||
|
<HoppSmartInput
|
||||||
|
v-model="versionModel"
|
||||||
|
:label="t('documentation.publish.doc_version')"
|
||||||
|
:disabled="mode === 'update'"
|
||||||
|
:input-styles="[
|
||||||
|
'floating-input',
|
||||||
|
!isValidVersion && versionModel.length > 0
|
||||||
|
? '!border-red-500 !focus:border-red-500'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="!isValidVersion && versionModel.length > 0"
|
||||||
|
class="text-xs text-red-500 mt-1 block"
|
||||||
|
>
|
||||||
|
{{ t("documentation.publish.invalid_version") }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="mode === 'create' && isValidVersion"
|
||||||
|
class="text-xs text-secondaryLight mt-1 block"
|
||||||
|
>
|
||||||
|
{{ t("documentation.publish.snapshot_description") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-sync Toggle (hidden for first publish and for live versions) -->
|
||||||
|
<div v-if="!isFirstPublish && !isAutoSyncLocked" class="flex items-start">
|
||||||
|
<HoppSmartCheckbox
|
||||||
|
:on="autoSyncModel"
|
||||||
|
@change="autoSyncModel = !autoSyncModel"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-secondaryDark">
|
||||||
|
{{ t("documentation.publish.auto_sync") }}
|
||||||
|
</span>
|
||||||
|
<span class="text-tiny text-secondaryLight">
|
||||||
|
({{ t("documentation.publish.auto_sync_description") }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</HoppSmartCheckbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Environment Selector -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="block text-sm font-medium text-secondaryDark">
|
||||||
|
{{ t("documentation.publish.environment") }}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-secondaryLight">
|
||||||
|
{{ t("documentation.publish.environment_description") }}
|
||||||
|
</p>
|
||||||
|
<p class="text-tiny text-secondaryLight !mt-1">
|
||||||
|
{{ t("documentation.publish.sensitive_data_warning") }}
|
||||||
|
</p>
|
||||||
|
<CollectionsDocumentationEnvironmentPicker
|
||||||
|
v-model="environmentModel"
|
||||||
|
:workspace-type="workspaceType"
|
||||||
|
:workspace-i-d="workspaceID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Published URL (shown after publishing or in update mode) -->
|
||||||
|
<div v-if="publishedUrl || mode === 'update'" class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<HoppSmartInput
|
||||||
|
:model-value="publishedUrl"
|
||||||
|
:label="t('documentation.publish.published_url')"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
input-styles="floating-input"
|
||||||
|
class="flex-1 opacity-80 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-if="publishedUrl"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('documentation.publish.copy_url')"
|
||||||
|
:icon="IconCopy"
|
||||||
|
outline
|
||||||
|
@click="$emit('copyUrl')"
|
||||||
|
/>
|
||||||
|
<HoppButtonPrimary
|
||||||
|
v-if="publishedUrl"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('documentation.publish.open_published_doc')"
|
||||||
|
:label="t('action.open')"
|
||||||
|
:icon="IconExternalLink"
|
||||||
|
@click="$emit('viewPublished')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { WorkspaceType } from "~/helpers/backend/graphql"
|
||||||
|
import IconCopy from "~icons/lucide/copy"
|
||||||
|
import IconExternalLink from "~icons/lucide/external-link"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
publishTitle: string
|
||||||
|
publishVersion: string
|
||||||
|
autoSync: boolean
|
||||||
|
selectedEnvironmentID: string | null
|
||||||
|
publishedUrl: string | null
|
||||||
|
isFirstPublish: boolean
|
||||||
|
isAutoSyncLocked: boolean
|
||||||
|
isValidVersion: boolean
|
||||||
|
workspaceType: WorkspaceType
|
||||||
|
workspaceID: string
|
||||||
|
mode: "create" | "update"
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:publishTitle", value: string): void
|
||||||
|
(e: "update:publishVersion", value: string): void
|
||||||
|
(e: "update:autoSync", value: boolean): void
|
||||||
|
(e: "update:selectedEnvironmentID", value: string | null): void
|
||||||
|
(e: "copyUrl"): void
|
||||||
|
(e: "viewPublished"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const titleModel = computed({
|
||||||
|
get: () => props.publishTitle,
|
||||||
|
set: (v) => emit("update:publishTitle", v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const versionModel = computed({
|
||||||
|
get: () => props.publishVersion,
|
||||||
|
set: (v) => emit("update:publishVersion", v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const autoSyncModel = computed({
|
||||||
|
get: () => props.autoSync,
|
||||||
|
set: (v) => emit("update:autoSync", v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const environmentModel = computed({
|
||||||
|
get: () => props.selectedEnvironmentID,
|
||||||
|
set: (v) => emit("update:selectedEnvironmentID", v),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -3,97 +3,41 @@
|
||||||
v-if="show"
|
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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 flex-1">
|
||||||
|
<!-- Left Metadata Panel -->
|
||||||
|
<div class="lg:w-72 flex-shrink-0 flex flex-col space-y-3">
|
||||||
|
<!-- Title & version header -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-sm font-semibold text-secondaryDark truncate">
|
||||||
|
{{ existingData?.title }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="text-xs text-secondaryLight rounded border border-dividerDark px-2 py-0.5"
|
||||||
|
>
|
||||||
|
{{ existingData?.version }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Environment badge -->
|
||||||
|
<div
|
||||||
|
v-if="existingData?.environmentName"
|
||||||
|
class="flex items-center space-x-1.5"
|
||||||
|
>
|
||||||
|
<icon-lucide-layers
|
||||||
|
class="w-3 h-3 text-secondaryLight flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-secondaryLight">
|
||||||
|
{{ existingData.environmentName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-divider" />
|
||||||
|
|
||||||
|
<!-- Published URL -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div v-if="publishedUrl" class="space-y-1">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-semibold uppercase tracking-wider text-secondaryLight"
|
||||||
|
>
|
||||||
|
{{ t("documentation.publish.published_url") }}
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="flex items-center rounded border border-divider bg-primaryLight"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex-1 px-2.5 py-1.5 text-xs text-secondary truncate select-all"
|
||||||
|
>
|
||||||
|
{{ publishedUrl }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center border-l border-divider">
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('documentation.publish.copy_url')"
|
||||||
|
:icon="copyIcon"
|
||||||
|
class="!rounded-none !px-2 !py-1.5"
|
||||||
|
@click="handleCopyUrl"
|
||||||
|
/>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="t('documentation.publish.open_published_doc')"
|
||||||
|
:icon="IconExternalLink"
|
||||||
|
class="!rounded-none !rounded-r !px-2 !py-1.5"
|
||||||
|
@click="$emit('viewPublished')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status notice -->
|
||||||
|
<div
|
||||||
|
v-if="existingData && !isLive"
|
||||||
|
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-primaryLight border border-divider"
|
||||||
|
>
|
||||||
|
<icon-lucide-lock
|
||||||
|
class="w-3.5 h-3.5 text-secondaryLight flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-secondaryLight leading-relaxed">
|
||||||
|
{{ t("documentation.publish.version_immutable") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="existingData && isLive"
|
||||||
|
class="flex items-start space-x-2 px-3 py-2.5 rounded-md bg-green-500/5 border border-green-500/15"
|
||||||
|
>
|
||||||
|
<icon-lucide-refresh-cw
|
||||||
|
class="w-3.5 h-3.5 text-green-600 flex-shrink-0 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-green-600 leading-relaxed">
|
||||||
|
{{ t("documentation.publish.auto_sync_live_notice") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Snapshot Preview -->
|
||||||
|
<div
|
||||||
|
class="flex-1 min-w-0 rounded-md border border-divider flex flex-col overflow-hidden bg-primary"
|
||||||
|
>
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div
|
||||||
|
v-if="isLoadingSnapshot"
|
||||||
|
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center space-y-3">
|
||||||
|
<HoppSmartSpinner />
|
||||||
|
<span class="text-xs text-secondaryLight">
|
||||||
|
{{ t("documentation.publish.loading_snapshot") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div
|
||||||
|
v-else-if="snapshotError"
|
||||||
|
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center space-y-3">
|
||||||
|
<icon-lucide-alert-circle class="w-5 h-5 text-red-400" />
|
||||||
|
<span class="text-xs text-secondaryLight">
|
||||||
|
{{ t("documentation.publish.snapshot_load_error") }}
|
||||||
|
</span>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="t('documentation.publish.retry_snapshot')"
|
||||||
|
:icon="IconRefreshCw"
|
||||||
|
outline
|
||||||
|
@click="fetchSnapshotPreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-else-if="!snapshotCollectionData"
|
||||||
|
class="flex items-center justify-center flex-1 min-h-[300px]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center space-y-2">
|
||||||
|
<icon-lucide-file-x class="w-5 h-5 text-secondaryLight" />
|
||||||
|
<span class="text-xs text-secondaryLight">
|
||||||
|
{{ t("documentation.publish.snapshot_empty") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snapshot content -->
|
||||||
|
<div v-else-if="snapshotCollectionData" class="h-[60vh] flex flex-col">
|
||||||
|
<DocumentationContent
|
||||||
|
:collection-data="snapshotCollectionData"
|
||||||
|
:all-items="snapshotItems"
|
||||||
|
:update-url-on-select="false"
|
||||||
|
:compact="true"
|
||||||
|
:is-doc-modal="true"
|
||||||
|
:environment-variables="snapshotEnvironmentVariables"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, markRaw, computed } from "vue"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import {
|
||||||
|
HoppCollection,
|
||||||
|
HoppRESTRequest,
|
||||||
|
Environment,
|
||||||
|
translateToNewEnvironmentVariables,
|
||||||
|
} from "@hoppscotch/data"
|
||||||
|
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||||
|
import {
|
||||||
|
getPublishedDocBySlugREST,
|
||||||
|
collectionFolderToHoppCollection,
|
||||||
|
} from "~/helpers/backend/queries/PublishedDocs"
|
||||||
|
import * as E from "fp-ts/Either"
|
||||||
|
import { refAutoReset } from "@vueuse/core"
|
||||||
|
|
||||||
|
import IconCopy from "~icons/lucide/copy"
|
||||||
|
import IconCheck from "~icons/lucide/check"
|
||||||
|
import IconExternalLink from "~icons/lucide/external-link"
|
||||||
|
import IconRefreshCw from "~icons/lucide/refresh-cw"
|
||||||
|
import { isLiveVersion } from "~/services/documentation.service"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
type ExistingData = {
|
||||||
|
title: string
|
||||||
|
version: string
|
||||||
|
autoSync: boolean
|
||||||
|
url: string
|
||||||
|
environmentName?: string | null
|
||||||
|
environmentID?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
existingData?: ExistingData
|
||||||
|
publishedUrl: string | null
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "copyUrl"): void
|
||||||
|
(e: "viewPublished"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
copyIcon.value = markRaw(IconCheck)
|
||||||
|
emit("copyUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot state
|
||||||
|
type SnapshotDocumentationItem = {
|
||||||
|
id: string
|
||||||
|
type: "folder" | "request"
|
||||||
|
item: HoppCollection | HoppRESTRequest
|
||||||
|
inheritedProperties: HoppInheritedProperty
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoadingSnapshot = ref(false)
|
||||||
|
const snapshotError = ref(false)
|
||||||
|
const snapshotCollectionData = ref<HoppCollection | null>(null)
|
||||||
|
const snapshotItems = ref<SnapshotDocumentationItem[]>([])
|
||||||
|
const snapshotEnvironmentVariables = ref<Environment["variables"]>([])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the currently displayed published doc is the live (current) version.
|
||||||
|
*/
|
||||||
|
const isLive = computed(() => {
|
||||||
|
if (!props.existingData) return true
|
||||||
|
return isLiveVersion(props.existingData)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts slug and version from a published doc URL
|
||||||
|
*/
|
||||||
|
const extractSlugFromUrl = (
|
||||||
|
url: string
|
||||||
|
): { slug: string; version: string } | null => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathParts = urlObj.pathname.split("/").filter(Boolean)
|
||||||
|
const viewIndex = pathParts.indexOf("view")
|
||||||
|
if (viewIndex !== -1 && pathParts.length > viewIndex + 2) {
|
||||||
|
return {
|
||||||
|
slug: pathParts[viewIndex + 1],
|
||||||
|
version: pathParts[viewIndex + 2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const match = url.match(/\/view\/([^/]+)\/([^/]+)/)
|
||||||
|
if (match) {
|
||||||
|
return { slug: match[1], version: match[2] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively flattens a collection into documentation items
|
||||||
|
*/
|
||||||
|
const flattenCollection = (
|
||||||
|
collection: HoppCollection,
|
||||||
|
items: SnapshotDocumentationItem[] = [],
|
||||||
|
inheritedProperties: HoppInheritedProperty | undefined = undefined
|
||||||
|
): SnapshotDocumentationItem[] => {
|
||||||
|
const currentInheritedProps: HoppInheritedProperty = {
|
||||||
|
auth:
|
||||||
|
collection.auth.authType === "inherit"
|
||||||
|
? (inheritedProperties?.auth ?? {
|
||||||
|
parentID: "",
|
||||||
|
parentName: "",
|
||||||
|
inheritedAuth: { authType: "none", authActive: true },
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
parentID: collection.id || "",
|
||||||
|
parentName: collection.name,
|
||||||
|
inheritedAuth: collection.auth,
|
||||||
|
},
|
||||||
|
headers: [
|
||||||
|
...(inheritedProperties?.headers || []),
|
||||||
|
...(collection.headers || []).map((h) => ({
|
||||||
|
parentID: collection.id || "",
|
||||||
|
parentName: collection.name,
|
||||||
|
inheritedHeader: h,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
variables: [
|
||||||
|
...(inheritedProperties?.variables || []),
|
||||||
|
{
|
||||||
|
parentID: collection.id || "",
|
||||||
|
parentName: collection.name,
|
||||||
|
inheritedVariables: (collection.variables || []).map((v) => ({
|
||||||
|
...v,
|
||||||
|
secret: v.secret,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection.folders && collection.folders.length > 0) {
|
||||||
|
collection.folders.forEach((folder: HoppCollection) => {
|
||||||
|
items.push({
|
||||||
|
id: folder.id || (folder as any)._ref_id || `folder-${folder.name}`,
|
||||||
|
type: "folder",
|
||||||
|
item: folder,
|
||||||
|
inheritedProperties: currentInheritedProps,
|
||||||
|
})
|
||||||
|
flattenCollection(folder, items, currentInheritedProps)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collection.requests && collection.requests.length > 0) {
|
||||||
|
;(collection.requests as HoppRESTRequest[]).forEach((request) => {
|
||||||
|
items.push({
|
||||||
|
id: request.id || (request as any)._ref_id || `request-${request.name}`,
|
||||||
|
type: "request",
|
||||||
|
item: request,
|
||||||
|
inheritedProperties: currentInheritedProps,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the snapshot documentTree via REST and converts it for display
|
||||||
|
*/
|
||||||
|
const fetchSnapshotPreview = async () => {
|
||||||
|
if (!props.existingData?.url) return
|
||||||
|
|
||||||
|
const parsed = extractSlugFromUrl(props.existingData.url)
|
||||||
|
if (!parsed) {
|
||||||
|
snapshotError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingSnapshot.value = true
|
||||||
|
snapshotError.value = false
|
||||||
|
snapshotCollectionData.value = null
|
||||||
|
snapshotItems.value = []
|
||||||
|
snapshotEnvironmentVariables.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getPublishedDocBySlugREST(
|
||||||
|
parsed.slug,
|
||||||
|
parsed.version
|
||||||
|
)()
|
||||||
|
|
||||||
|
if (E.isLeft(result)) {
|
||||||
|
snapshotError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse environment variables from the snapshot
|
||||||
|
const rawEnvVars = result.right.environmentVariables
|
||||||
|
if (rawEnvVars) {
|
||||||
|
try {
|
||||||
|
const parsedVars =
|
||||||
|
typeof rawEnvVars === "string" ? JSON.parse(rawEnvVars) : rawEnvVars
|
||||||
|
if (Array.isArray(parsedVars)) {
|
||||||
|
snapshotEnvironmentVariables.value = parsedVars.map((v) => {
|
||||||
|
const normalized = translateToNewEnvironmentVariables(v)
|
||||||
|
// Ensure currentValue falls back to initialValue
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
currentValue: normalized.currentValue || normalized.initialValue,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing snapshot environment variables:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedData = JSON.parse(result.right.documentTree)
|
||||||
|
const hoppCollection = collectionFolderToHoppCollection(publishedData)
|
||||||
|
snapshotCollectionData.value = hoppCollection
|
||||||
|
snapshotItems.value = flattenCollection(hoppCollection)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading snapshot preview:", error)
|
||||||
|
snapshotError.value = true
|
||||||
|
} finally {
|
||||||
|
isLoadingSnapshot.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
snapshotCollectionData.value = null
|
||||||
|
snapshotItems.value = []
|
||||||
|
snapshotError.value = false
|
||||||
|
snapshotEnvironmentVariables.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch snapshot when component becomes visible
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(isVisible) => {
|
||||||
|
if (isVisible && props.existingData?.url) {
|
||||||
|
fetchSnapshotPreview()
|
||||||
|
} else if (!isVisible) {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
@ -44,7 +44,6 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>("")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) {
|
||||||
version
|
version
|
||||||
autoSync
|
autoSync
|
||||||
url
|
url
|
||||||
|
environmentName
|
||||||
createdOn
|
createdOn
|
||||||
updatedOn
|
updatedOn
|
||||||
workspaceType
|
workspaceType
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) {
|
||||||
version
|
version
|
||||||
autoSync
|
autoSync
|
||||||
url
|
url
|
||||||
|
environmentName
|
||||||
createdOn
|
createdOn
|
||||||
updatedOn
|
updatedOn
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ query PublishedDoc($id: ID!) {
|
||||||
autoSync
|
autoSync
|
||||||
url
|
url
|
||||||
metadata
|
metadata
|
||||||
|
environmentName
|
||||||
|
environmentVariables
|
||||||
createdOn
|
createdOn
|
||||||
updatedOn
|
updatedOn
|
||||||
creator {
|
creator {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ query TeamPublishedDocsList(
|
||||||
version
|
version
|
||||||
autoSync
|
autoSync
|
||||||
url
|
url
|
||||||
|
documentTree
|
||||||
|
environmentName
|
||||||
collection {
|
collection {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ query UserPublishedDocsList($skip: Int!, $take: Int!) {
|
||||||
version
|
version
|
||||||
autoSync
|
autoSync
|
||||||
url
|
url
|
||||||
|
documentTree
|
||||||
|
environmentName
|
||||||
collection {
|
collection {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue