feat: improve documentation UI and add published docs indicators (#5620)

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
This commit is contained in:
Nivedin 2025-11-27 12:29:29 +05:30 committed by GitHub
parent 1e8edd2c9c
commit ab52efc075
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 613 additions and 195 deletions

View file

@ -34,6 +34,10 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
})
export class AuthModule {
static async register() {
if (process.env.GENERATE_GQL_SCHEMA === 'true') {
return { module: AuthModule };
}
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
if (!isInfraConfigPopulated) {
return { module: AuthModule };

View file

@ -119,8 +119,9 @@ export class PublishedDocsResolver {
name: 'collectionID',
type: () => ID,
description: 'Id of the collection to add to',
nullable: true,
})
collectionID: string,
collectionID: string | undefined,
@Args() args: OffsetPaginationArgs,
) {
const docs = await this.publishedDocsService.getAllTeamPublishedDocs(

View file

@ -23,7 +23,6 @@ import {
import { TeamAccessRole } from 'src/team/team.model';
import { TreeLevel } from './published-docs.dto';
import { ConfigService } from '@nestjs/config';
import { right } from 'fp-ts/lib/EitherT';
const mockPrisma = mockDeep<PrismaService>();
const mockUserCollectionService = mockDeep<UserCollectionService>();

View file

@ -23,7 +23,6 @@ import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { stringToJson } from 'src/utils';
import { UserCollectionService } from 'src/user-collection/user-collection.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { CollectionFolder } from 'src/types/CollectionFolder';
import { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto';
import { ConfigService } from '@nestjs/config';
@ -275,7 +274,7 @@ export class PublishedDocsService {
*/
async getAllTeamPublishedDocs(
teamID: string,
collectionID: string,
collectionID: string | undefined,
args: OffsetPaginationArgs,
) {
const docs = await this.prisma.publishedDocs.findMany({

View file

@ -18,7 +18,6 @@ import {
UserCollection,
UserCollectionDuplicatedData,
UserCollectionExportJSONData,
UserCollectionImportResult,
UserCollectionRemovedData,
UserCollectionReorderData,
} from './user-collections.model';

View file

@ -41,6 +41,7 @@
"more": "More",
"new": "New",
"no": "No",
"open": "Open",
"open_workspace": "Open workspace",
"paste": "Paste",
"prettify": "Prettify",
@ -69,6 +70,7 @@
"turn_off": "Turn off",
"turn_on": "Turn on",
"undo": "Undo",
"unpublish": "Unpublish",
"yes": "Yes",
"verify": "Verify",
"enable": "Enable",
@ -504,13 +506,15 @@
"auto_sync_description": "Automatically update published docs when collection changes",
"button": "Publish",
"copy_url": "Copy URL",
"delete_published_doc": "Are you sure you want to delete the published documentation?",
"delete": "Delete Documentation",
"unpublish_doc": "Are you sure you want to unpublish the documentation?",
"delete_success": "Published documentation deleted successfully",
"doc_title": "Title",
"doc_version": "Version",
"edit_published_doc": "Edit Published Doc",
"last_updated": "Last Updated",
"metadata": "Metadata (JSON)",
"open_published_doc": "Open Published Documentation in new tab",
"publish_error": "Failed to publish documentation",
"publish_success": "Documentation published successfully!",
"published": "Published",
@ -520,6 +524,7 @@
"update_error": "Failed to update documentation",
"update_published_docs": "Update Published Docs",
"update_success": "Documentation updated successfully!",
"unpublish": "Unpublish",
"update_title": "Update Published Documentation",
"url_copied": "URL copied to clipboard!",
"view_published": "View Published Docs",

View file

@ -76,8 +76,18 @@
}"
/>
</span>
<!-- Published Doc Status Indicator -->
<span
v-if="publishedDocStatus"
v-tippy="{ theme: 'tooltip' }"
:title="t('documentation.publish.published')"
class="ml-2 flex items-center"
>
<component :is="IconGlobe" class="svg-icons text-green-500" />
</span>
</span>
</div>
<div
v-if="isCollectionLoading && !isOpen"
class="flex items-center px-2"
@ -179,6 +189,19 @@
}
"
/>
<HoppSmartItem
v-if="isDocumentationVisible"
ref="documentationAction"
:icon="IconBook"
:label="t('documentation.title')"
:shortcut="['I']"
@click="
() => {
handleDocumentationAction()
hide()
}
"
/>
<HoppSmartItem
v-if="
!hasNoTeamAccess &&
@ -250,19 +273,7 @@
}
"
/>
<HoppSmartItem
v-if="isDocumentationVisible"
ref="documentationAction"
:icon="IconBook"
:label="t('documentation.title')"
:shortcut="['I']"
@click="
() => {
handleDocumentationAction()
hide()
}
"
/>
<HoppSmartItem
ref="propertiesAction"
:icon="IconSettings2"
@ -346,6 +357,8 @@ import { useMockServerStatus } from "~/composables/mockServer"
import { useMockServerVisibility } from "~/composables/mockServerVisibility"
import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
import { DocumentationService } from "~/services/documentation.service"
import IconGlobe from "~icons/lucide/globe"
type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder"
@ -500,6 +513,19 @@ const mockServerStatus = computed(() => {
return getMockServerStatus(collectionId || "")
})
// Published Doc Status
const documentationService = useService(DocumentationService)
const publishedDocStatus = computed(() => {
const collectionId =
props.collectionsType === "my-collections"
? ((props.data as HoppCollection).id ??
(props.data as HoppCollection)._ref_id)
: (props.data as TeamCollection).id
return documentationService.getPublishedDocStatus(collectionId || "")
})
// Determine if this is a root collection (not a child folder)
const isRootCollection = computed(() => {
return props.folderType === "collection"

View file

@ -875,18 +875,13 @@ const updateCollectionOrder = (
}
const debouncedSorting = useDebounceFn(() => {
sortCollection()
}, 250)
const sortCollection = () => {
currentSortOrder.value = currentSortOrder.value === "asc" ? "desc" : "asc"
emit("sort-collections", {
collectionID: null,
sortOrder: currentSortOrder.value,
collectionRefID: "personal",
})
}
}, 250)
type MyCollectionNode = Collection | Folder | Requests

View file

@ -1,11 +1,15 @@
<template>
<div class="rounded-sm relative h-full" @click.stop>
<div
v-if="!(readOnly && isEmpty)"
class="rounded-sm relative h-full"
@click.stop
>
<!-- Edit mode textarea -->
<template v-if="editMode && !readOnly">
<textarea
ref="textareaRef"
v-model="internalContent"
class="text-wrap w-full p-4 rounded-sm text-sm font-mono text-secondaryLight outline-none resize-none focus:border focus:border-accent focus:bg-primaryLight transition"
class="text-wrap w-full p-4 rounded-sm text-sm font-mono text-secondary outline-none resize-none focus:border focus:border-accent focus:bg-primaryLight transition placeholder:text-secondaryLight"
:style="{ height: textareaHeight + 'px' }"
spellcheck="false"
:placeholder="placeholder"
@ -74,6 +78,11 @@ const textareaHeight = ref<number>(200)
// Internal content that syncs with modelValue
const internalContent = ref<string>(props.modelValue)
// Check if the content is empty
const isEmpty = computed(
() => !internalContent.value || internalContent.value.trim() === ""
)
// Watch for external changes to modelValue
watch(
() => props.modelValue,
@ -88,7 +97,7 @@ watch(
// Render markdown content with DOMPurify sanitization
const renderedMarkdown = computed(() => {
try {
if (!internalContent.value || internalContent.value.trim() === "") {
if (isEmpty.value) {
return DOMPurify.sanitize(
`<p class='text-secondaryLight italic'>${props.placeholder || t("documentation.add_description_placeholder")}</p>`
)
@ -201,7 +210,7 @@ onMounted(() => {
/* List styles */
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
@apply pl-6 my-3 text-sm text-secondaryLight space-y-1;
@apply pl-6 my-3 text-sm text-secondary space-y-1;
}
.markdown-content :deep(li > ul),
@ -252,7 +261,7 @@ onMounted(() => {
}
.markdown-content :deep(td) {
@apply border border-divider px-3 py-1 text-secondaryLight;
@apply border border-divider px-3 py-1 text-secondary;
@apply bg-primaryDark;
}

View file

@ -70,21 +70,56 @@
input-styles="floating-input"
class="flex-1 opacity-80 cursor-not-allowed"
/>
<HoppButtonSecondary :icon="copyIcon" outline @click="copyUrl" />
<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>
<div class="flex space-x-2">
<template #footer>
<div class="flex justify-between items-center flex-1">
<div class="flex items-center w-full space-x-2">
<HoppButtonPrimary
v-if="mode === 'view' || mode === 'update'"
:label="t('documentation.publish.view_published')"
:icon="IconExternalLink"
@click="viewPublished"
v-if="mode === 'create' && !publishedUrl"
:label="t('documentation.publish.button')"
:disabled="!canPublish || loading"
:loading="loading"
@click="handlePublish"
/>
<HoppButtonPrimary
v-else-if="mode === 'update'"
:label="t('documentation.publish.update_button')"
:disabled="!canPublish || loading || !hasChanges"
:loading="loading"
@click="handleUpdate"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</div>
<div class="flex">
<HoppButtonSecondary
v-if="mode === 'update'"
:icon="IconTrash2"
label="Delete Documentation"
:label="t('documentation.publish.unpublish')"
class="!text-red-500"
:loading="loading"
:disabled="loading"
@ -95,37 +130,12 @@
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center w-full">
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
<HoppButtonPrimary
v-if="mode === 'create' && !publishedUrl"
:label="t('documentation.publish.button')"
:disabled="!canPublish || loading"
:loading="loading"
@click="handlePublish"
/>
<HoppButtonPrimary
v-else-if="mode === 'update'"
:label="t('documentation.publish.update_button')"
:disabled="!canPublish || loading || !hasChanges"
:loading="loading"
@click="handleUpdate"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="showDeleteConfirmModal"
:title="t('documentation.publish.delete_published_doc')"
:confirm="t('action.delete')"
:title="t('documentation.publish.unpublish_doc')"
:confirm="t('action.unpublish')"
:loading-state="loading"
@hide-modal="showDeleteConfirmModal = false"
@resolve="confirmDelete"

View file

@ -51,12 +51,6 @@
<template #footer>
<div class="flex justify-between items-center w-full">
<span class="flex space-x-2">
<HoppButtonSecondary
:label="t('action.close')"
outline
filled
@click="hideModal"
/>
<HoppButtonPrimary
v-if="hasTeamWriteAccess"
:label="t('action.save')"
@ -66,28 +60,29 @@
filled
@click="saveDocumentation"
/>
<!-- Publish Button - Simple button when not published -->
<HoppButtonSecondary
:label="t('action.close')"
outline
filled
@click="hideModal"
/>
</span>
<div class="flex space-x-2 items-center">
<!-- Publish Button - Simple button when not published -->
<HoppButtonSecondary
v-if="
currentCollection && !isCollectionPublished && hasTeamWriteAccess
"
:icon="isCheckingPublishedStatus ? IconLoader2 : IconShare2"
:icon="IconShare2"
:label="t('documentation.publish.button')"
:loading="isCheckingPublishedStatus"
:disabled="isCheckingPublishedStatus"
outline
filled
@click="openPublishModal"
/>
<tippy
v-else-if="
currentCollection &&
isCollectionPublished &&
!isCheckingPublishedStatus &&
hasTeamWriteAccess
currentCollection && isCollectionPublished && hasTeamWriteAccess
"
ref="publishedDropdown"
interactive
@ -104,8 +99,6 @@
:icon="IconCheveronDown"
reverse
:label="t('documentation.publish.published')"
:loading="isCheckingPublishedStatus"
:disabled="isCheckingPublishedStatus"
class="!pr-2"
/>
</div>
@ -126,7 +119,7 @@
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:title="t('documentation.publish.copy_url')"
:icon="copyIcon"
@click="copyPublishedUrl"
/>
@ -235,10 +228,6 @@ import {
deletePublishedDoc,
updatePublishedDoc,
} from "~/helpers/backend/mutations/PublishedDocs"
import {
getUserPublishedDocs,
getTeamPublishedDocs,
} from "~/helpers/backend/queries/PublishedDocs"
import { TippyComponent } from "vue-tippy"
@ -284,7 +273,6 @@ const documentationService = useService(DocumentationService)
const isLoadingTeamCollection = ref<boolean>(false)
const isSavingDocumentation = ref<boolean>(false)
const isCheckingPublishedStatus = ref<boolean>(false)
const isProcessingPublish = ref<boolean>(false)
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
@ -299,17 +287,22 @@ const publishedDropdown = ref<TippyComponent | null>(null)
const publishedDropdownActions = ref<HTMLDivElement | null>(null)
// Published docs state
const isCollectionPublished = ref<boolean>(false)
const publishedDocId = ref<string | undefined>(undefined)
const existingPublishedData = ref<
| {
title: string
version: string
autoSync: boolean
url: string
}
| undefined
>(undefined)
const publishedDocStatus = computed(() => {
if (!props.collectionID) return undefined
return documentationService.getPublishedDocStatus(props.collectionID)
})
const isCollectionPublished = computed(() => !!publishedDocStatus.value)
const publishedDocId = computed(() => publishedDocStatus.value?.id)
const existingPublishedData = computed(() => {
if (!publishedDocStatus.value) return undefined
return {
title: publishedDocStatus.value.title,
version: publishedDocStatus.value.version,
autoSync: publishedDocStatus.value.autoSync,
url: publishedDocStatus.value.url,
}
})
const publishModalMode = computed<"create" | "update" | "view">(() => {
return isCollectionPublished.value ? "update" : "create"
@ -412,81 +405,12 @@ const handleToggleAllDocumentation = async () => {
}
}
// Check for existing published docs status
const checkPublishedDocsStatus = async () => {
if (!props.collectionID) return
isCheckingPublishedStatus.value = true
isCollectionPublished.value = false
publishedDocId.value = undefined
existingPublishedData.value = undefined
// Check if collection is already published
if (props.isTeamCollection && props.teamID) {
await pipe(
getTeamPublishedDocs(props.teamID, props.collectionID),
TE.match(
(error) => {
console.error("No published docs found or error:", error)
isCheckingPublishedStatus.value = false
},
(docs) => {
// Find published doc for this collection
const publishedDoc = docs.find(
(doc) => doc.collection.id === props.collectionID
)
if (publishedDoc) {
isCollectionPublished.value = true
publishedDocId.value = publishedDoc.id
existingPublishedData.value = {
title: publishedDoc.title,
version: publishedDoc.version,
autoSync: publishedDoc.autoSync,
url: publishedDoc.url,
}
}
isCheckingPublishedStatus.value = false
}
)
)()
} else {
await pipe(
getUserPublishedDocs(),
TE.match(
(error) => {
console.error("No published docs found or error:", error)
isCheckingPublishedStatus.value = false
},
(docs) => {
// Find published doc for this collection
const publishedDoc = docs.find(
(doc) => doc.collection.id === props.collectionID
)
if (publishedDoc) {
isCollectionPublished.value = true
publishedDocId.value = publishedDoc.id
existingPublishedData.value = {
title: publishedDoc.title,
version: publishedDoc.version,
autoSync: publishedDoc.autoSync,
url: publishedDoc.url,
}
}
isCheckingPublishedStatus.value = false
}
)
)()
}
}
// Reset fetched collection data when modal opens/closes
watch(
() => props.show,
async (newVal) => {
if (newVal) {
// Check for existing published docs when modal opens
await checkPublishedDocsStatus()
// No need to manually check published docs status as it is now reactive
} else {
// Reset when modal closes
fullCollectionData.value = null
@ -874,16 +798,21 @@ const handlePublish = async (doc: CreatePublishedDocsArgs) => {
const url = data.createPublishedDoc.url
toast.success(t("documentation.publish.publish_success"))
// Update state
isCollectionPublished.value = true
publishedDocId.value = data.createPublishedDoc.id
existingPublishedData.value = {
const newDocInfo = {
id: data.createPublishedDoc.id,
title: doc.title,
version: doc.version,
autoSync: doc.autoSync,
url: url,
}
// Update service
if (props.collectionID) {
documentationService.setPublishedDocStatus(
props.collectionID,
newDocInfo
)
}
}
)
)()
@ -904,12 +833,21 @@ const handleUpdate = async (id: string, doc: UpdatePublishedDocsArgs) => {
toast.success(t("documentation.publish.update_success"))
// Update existing data
if (existingPublishedData.value) {
existingPublishedData.value = {
const updatedDocInfo = {
id: id,
title: data.updatePublishedDoc.title,
version: data.updatePublishedDoc.version,
autoSync: data.updatePublishedDoc.autoSync,
url: url,
}
// Update service
if (props.collectionID) {
documentationService.setPublishedDocStatus(
props.collectionID,
updatedDocInfo
)
}
}
}
)
@ -931,10 +869,12 @@ const handleDelete = async () => {
() => {
toast.success(t("documentation.publish.delete_success"))
isCollectionPublished.value = false
publishedDocId.value = undefined
existingPublishedData.value = undefined
showPublishModal.value = false
// Update service
if (props.collectionID) {
documentationService.setPublishedDocStatus(props.collectionID, null)
}
}
)
)()

View file

@ -10,7 +10,9 @@
</span>
</div>
<div class="flex items-center gap-4">
<span class="text-md font-bold text-secondaryDark">
<span
class="text-md font-bold text-secondaryDark px-6 py-1 rounded-full border border-dividerDark shadow"
>
{{ publishedDoc?.title || "Untitled Project" }}
</span>
<!-- TODO: Add version (will be added in next iteration) -->

View file

@ -1,6 +1,6 @@
query TeamPublishedDocsList(
$teamID: ID!
$collectionID: ID!
$collectionID: ID
$skip: Int!
$take: Int!
) {

View file

@ -138,7 +138,7 @@ export const getUserPublishedDocs = (skip: number = 0, take: number = 100) =>
export const getTeamPublishedDocs = (
teamID: string,
collectionID: string,
collectionID?: string,
skip: number = 0,
take: number = 100
) =>

View file

@ -46,6 +46,7 @@ import { PublishedDocs } from "~/helpers/backend/graphql"
import { getKernelMode } from "@hoppscotch/kernel"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import { usePageHead } from "~/composables/head"
const route = useRoute()
const t = useI18n()
@ -203,6 +204,74 @@ onMounted(async () => {
loading.value = false
})
usePageHead({
title: computed(
() => publishedDoc.value?.title || "Hoppscotch Documentation"
),
meta: [
{
name: "description",
content: computed(
() =>
collectionData.value?.description ||
"Hoppscotch API Documentation - Open source API development ecosystem"
),
},
{
property: "og:title",
content: computed(
() => publishedDoc.value?.title || "Hoppscotch Documentation"
),
},
{
property: "og:description",
content: computed(
() =>
collectionData.value?.description ||
"Hoppscotch API Documentation - Open source API development ecosystem"
),
},
{
property: "og:site_name",
content: "Hoppscotch",
},
{
property: "og:image",
content: "https://hoppscotch.io/banner.png",
},
{
name: "twitter:card",
content: "summary_large_image",
},
{
name: "twitter:site",
content: "@hoppscotch_io",
},
{
name: "twitter:creator",
content: "@hoppscotch_io",
},
{
name: "twitter:title",
content: computed(
() => publishedDoc.value?.title || "Hoppscotch Documentation"
),
},
{
name: "twitter:description",
content: computed(
() =>
collectionData.value?.description ||
"Hoppscotch API Documentation - Open source API development ecosystem"
),
},
{
name: "twitter:image",
content: "https://hoppscotch.io/banner.png",
},
],
})
</script>
<route lang="yaml">

View file

@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"
import { describe, it, expect, beforeEach, vi } from "vitest"
import * as E from "fp-ts/Either"
import { TestContainer } from "dioc/testing"
import {
HoppCollection,
@ -13,6 +14,15 @@ import {
SetCollectionDocumentationOptions,
SetRequestDocumentationOptions,
} from "../documentation.service"
import {
getUserPublishedDocs,
getTeamPublishedDocs,
} from "~/helpers/backend/queries/PublishedDocs"
vi.mock("~/helpers/backend/queries/PublishedDocs", () => ({
getUserPublishedDocs: vi.fn(),
getTeamPublishedDocs: vi.fn(),
}))
describe("DocumentationService", () => {
let container: TestContainer
@ -451,4 +461,187 @@ describe("DocumentationService", () => {
expect(service.hasChanges.value).toBe(false)
})
})
describe("Published Documentation", () => {
it("should fetch user published docs and update map", async () => {
const mockDocs = [
{
id: "doc-1",
collection: { id: "col-1" },
title: "Doc 1",
version: "v1",
autoSync: true,
url: "url-1",
},
]
vi.mocked(getUserPublishedDocs).mockReturnValue(() =>
Promise.resolve(E.right(mockDocs as any))
)
await service.fetchUserPublishedDocs()
const status = service.getPublishedDocStatus("col-1")
expect(status).toEqual({
id: "doc-1",
title: "Doc 1",
version: "v1",
autoSync: true,
url: "url-1",
})
})
it("should fetch team published docs and update map", async () => {
const mockDocs = [
{
id: "doc-2",
collection: { id: "col-2" },
title: "Doc 2",
version: "v2",
autoSync: false,
url: "url-2",
},
]
vi.mocked(getTeamPublishedDocs).mockReturnValue(() =>
Promise.resolve(E.right(mockDocs as any))
)
await service.fetchTeamPublishedDocs("team-1")
const status = service.getPublishedDocStatus("col-2")
expect(status).toEqual({
id: "doc-2",
title: "Doc 2",
version: "v2",
autoSync: false,
url: "url-2",
})
})
it("should handle error when fetching user published docs", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.mocked(getUserPublishedDocs).mockReturnValue(() =>
Promise.resolve(E.left("user/not_authenticated"))
)
await service.fetchUserPublishedDocs()
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to fetch user published docs:",
"user/not_authenticated"
)
consoleSpy.mockRestore()
})
it("should handle error when fetching team published docs", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
vi.mocked(getTeamPublishedDocs).mockReturnValue(() =>
Promise.resolve(E.left("team/not_required" as any))
)
await service.fetchTeamPublishedDocs("team-1")
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to fetch team published docs:",
"team/not_required"
)
consoleSpy.mockRestore()
})
it("should manually set published doc status", () => {
const info = {
id: "doc-3",
title: "Doc 3",
version: "v3",
autoSync: true,
url: "url-3",
}
service.setPublishedDocStatus("col-3", info)
expect(service.getPublishedDocStatus("col-3")).toEqual(info)
})
it("should remove published doc status", () => {
const info = {
id: "doc-3",
title: "Doc 3",
version: "v3",
autoSync: true,
url: "url-3",
}
service.setPublishedDocStatus("col-3", info)
expect(service.getPublishedDocStatus("col-3")).toBeDefined()
service.setPublishedDocStatus("col-3", null)
expect(service.getPublishedDocStatus("col-3")).toBeUndefined()
})
it("should handle race conditions by ignoring stale responses", async () => {
const slowDocs = [
{
id: "doc-slow",
collection: { id: "col-1" },
title: "Slow Doc",
version: "v1",
autoSync: true,
url: "url-slow",
},
]
const fastDocs = [
{
id: "doc-fast",
collection: { id: "col-1" },
title: "Fast Doc",
version: "v2",
autoSync: true,
url: "url-fast",
},
]
let resolveSlow: (value: any) => void
const slowPromise = new Promise((resolve) => {
resolveSlow = resolve
})
// Mock the first call to be slow
vi.mocked(getUserPublishedDocs)
.mockReturnValueOnce(() => slowPromise as any)
.mockReturnValueOnce(() => Promise.resolve(E.right(fastDocs as any)))
// Start the slow request
const firstCall = service.fetchUserPublishedDocs()
// Start the fast request immediately after
const secondCall = service.fetchUserPublishedDocs()
// Wait for the fast request to complete
await secondCall
// Verify the fast response is applied
expect(service.getPublishedDocStatus("col-1")).toEqual({
id: "doc-fast",
title: "Fast Doc",
version: "v2",
autoSync: true,
url: "url-fast",
})
// Now resolve the slow request
resolveSlow!(E.right(slowDocs as any))
await firstCall
// Verify the state hasn't changed (slow response ignored)
expect(service.getPublishedDocStatus("col-1")).toEqual({
id: "doc-fast",
title: "Fast Doc",
version: "v2",
autoSync: true,
url: "url-fast",
})
})
})
})

View file

@ -37,6 +37,18 @@ vi.mock("../team-collection.service", () => ({
},
}))
// Mock DocumentationService
vi.mock("../documentation.service", () => ({
DocumentationService: class MockDocumentationService {
static readonly ID = "DOCUMENTATION_SERVICE"
fetchTeamPublishedDocs = vi.fn()
fetchUserPublishedDocs = vi.fn()
onServiceInit = vi.fn()
},
}))
describe("WorkspaceService", () => {
const platformMock = {
auth: {
@ -252,13 +264,14 @@ describe("WorkspaceService", () => {
})
})
describe("Team Collection Service Synchronization", () => {
it("should call changeTeamID when workspace changes to a team workspace", async () => {
describe("Workspace Synchronization", () => {
it("should call changeTeamID and fetchTeamPublishedDocs when workspace changes to a team workspace", async () => {
const container = new TestContainer()
const service = container.bind(WorkspaceService)
// Access the team collection service mock
// Access the mocks
const teamCollectionServiceMock = (service as any).teamCollectionService
const documentationServiceMock = (service as any).documentationService
// Change to team workspace
service.changeWorkspace({
@ -273,9 +286,12 @@ describe("WorkspaceService", () => {
expect(teamCollectionServiceMock.changeTeamID).toHaveBeenCalledWith(
"team-123"
)
expect(
documentationServiceMock.fetchTeamPublishedDocs
).toHaveBeenCalledWith("team-123")
})
it("should call clearCollections when workspace changes to personal workspace", async () => {
it("should call clearCollections and fetchUserPublishedDocs when workspace changes to personal workspace", async () => {
const container = new TestContainer()
const service = container.bind(WorkspaceService)
@ -290,7 +306,10 @@ describe("WorkspaceService", () => {
await nextTick()
const teamCollectionServiceMock = (service as any).teamCollectionService
const documentationServiceMock = (service as any).documentationService
teamCollectionServiceMock.clearCollections.mockClear()
documentationServiceMock.fetchUserPublishedDocs.mockClear()
// Change to personal workspace
service.changeWorkspace({
@ -300,13 +319,15 @@ describe("WorkspaceService", () => {
await nextTick()
expect(teamCollectionServiceMock.clearCollections).toHaveBeenCalled()
expect(documentationServiceMock.fetchUserPublishedDocs).toHaveBeenCalled()
})
it("should call clearCollections when workspace changes to team workspace without teamID", async () => {
it("should call clearCollections and fetchUserPublishedDocs when workspace changes to team workspace without teamID", async () => {
const container = new TestContainer()
const service = container.bind(WorkspaceService)
const teamCollectionServiceMock = (service as any).teamCollectionService
const documentationServiceMock = (service as any).documentationService
// Change to team workspace without teamID
service.changeWorkspace({
@ -319,6 +340,7 @@ describe("WorkspaceService", () => {
await nextTick()
expect(teamCollectionServiceMock.clearCollections).toHaveBeenCalled()
expect(documentationServiceMock.fetchUserPublishedDocs).toHaveBeenCalled()
})
it("should not sync when workspaces are effectively the same", async () => {
@ -336,7 +358,10 @@ describe("WorkspaceService", () => {
await nextTick()
const teamCollectionServiceMock = (service as any).teamCollectionService
const documentationServiceMock = (service as any).documentationService
teamCollectionServiceMock.changeTeamID.mockClear()
documentationServiceMock.fetchTeamPublishedDocs.mockClear()
// Change to same team workspace (different name, same ID)
service.changeWorkspace({
@ -348,11 +373,14 @@ describe("WorkspaceService", () => {
await nextTick()
// Should not call changeTeamID again since it's the same team
// Should not call sync methods again since it's the same team
expect(teamCollectionServiceMock.changeTeamID).not.toHaveBeenCalled()
expect(
documentationServiceMock.fetchTeamPublishedDocs
).not.toHaveBeenCalled()
})
it("should handle errors during team collection service sync gracefully", async () => {
it("should handle errors during synchronization gracefully", async () => {
const container = new TestContainer()
const service = container.bind(WorkspaceService)
@ -376,7 +404,7 @@ describe("WorkspaceService", () => {
await nextTick()
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to sync team collections:",
"Failed to sync team collections and published docs:",
expect.any(Error)
)

View file

@ -1,10 +1,24 @@
import { Service } from "dioc"
import { reactive, computed } from "vue"
import { reactive, computed, ref } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import {
getUserPublishedDocs,
getTeamPublishedDocs,
} from "~/helpers/backend/queries/PublishedDocs"
import * as E from "fp-ts/Either"
// Types for documentation
export type DocumentationType = "collection" | "request"
// Published documentation info
export interface PublishedDocInfo {
id: string
title: string
version: string
autoSync: boolean
url: string
}
/**
* Base documentation item with common properties
*/
@ -91,6 +105,17 @@ export class DocumentationService extends Service {
*/
public hasChanges = computed(() => this.editedDocumentation.size > 0)
/**
* Map to store published docs
*/
private publishedDocsMap = ref<Map<string, PublishedDocInfo>>(new Map())
/**
* Counter to track the latest fetch request ID
* This prevents race conditions where a stale request overwrites a newer one
*/
private fetchRequestId = 0
/**
* Sets collection documentation
*/
@ -226,4 +251,109 @@ export class DocumentationService extends Service {
public getChangesCount(): number {
return this.editedDocumentation.size
}
/**
* Fetches user published docs and updates the map
*/
public async fetchUserPublishedDocs() {
// Increment request ID to invalidate any previous pending requests
const requestId = ++this.fetchRequestId
try {
const result = await getUserPublishedDocs()()
// If a newer request has started, ignore this result
if (requestId !== this.fetchRequestId) return
if (E.isRight(result)) {
const docs = result.right
const newMap = new Map<string, PublishedDocInfo>()
docs.forEach((doc) => {
if (doc.collection?.id) {
newMap.set(doc.collection.id, {
id: doc.id,
title: doc.title,
version: doc.version,
autoSync: doc.autoSync,
url: doc.url,
})
}
})
this.publishedDocsMap.value = newMap
} else {
console.error("Failed to fetch user published docs:", result.left)
}
} catch (error) {
// If a newer request has started, ignore this error
if (requestId !== this.fetchRequestId) return
console.error("Failed to fetch user published docs:", error)
}
}
/**
* Fetches published docs for team collections
*/
public async fetchTeamPublishedDocs(teamID: string) {
// Increment request ID to invalidate any previous pending requests
const requestId = ++this.fetchRequestId
try {
// Fetch all published docs for the team (collectionID is optional now)
const result = await getTeamPublishedDocs(teamID)()
// If a newer request has started, ignore this result
if (requestId !== this.fetchRequestId) return
if (E.isRight(result)) {
const docs = result.right
const newMap = new Map<string, PublishedDocInfo>()
docs.forEach((doc) => {
if (doc.collection?.id) {
newMap.set(doc.collection.id, {
id: doc.id,
title: doc.title,
version: doc.version,
autoSync: doc.autoSync,
url: doc.url,
})
}
})
this.publishedDocsMap.value = newMap
} else {
console.error("Failed to fetch team published docs:", result.left)
}
} catch (error) {
// If a newer request has started, ignore this error
if (requestId !== this.fetchRequestId) return
console.error("Failed to fetch team published docs:", error)
}
}
/**
* Gets the published status of a collection
* @param collectionId The ID of the collection
*/
public getPublishedDocStatus(
collectionId: string
): PublishedDocInfo | undefined {
return this.publishedDocsMap.value.get(collectionId)
}
/**
* Manually updates the published status of a collection
* @param collectionId The ID of the collection
* @param info The new info or null to remove
*/
public setPublishedDocStatus(
collectionId: string,
info: PublishedDocInfo | null
) {
const newMap = new Map(this.publishedDocsMap.value)
if (info) {
newMap.set(collectionId, info)
} else {
newMap.delete(collectionId)
}
this.publishedDocsMap.value = newMap
}
}

View file

@ -7,6 +7,7 @@ import { platform } from "~/platform"
import { min } from "lodash-es"
import { TeamAccessRole } from "~/helpers/backend/graphql"
import { TeamCollectionsService } from "./team-collection.service"
import { DocumentationService } from "./documentation.service"
/**
* Defines a workspace and its information
@ -47,6 +48,7 @@ export class WorkspaceService extends Service<WorkspaceServiceEvent> {
private managedTeamListAdapter = new TeamListAdapter(true, false)
private teamCollectionService = this.bind(TeamCollectionsService)
private documentationService = this.bind(DocumentationService)
private currentUser = useStreamStatic(
platform.auth.getCurrentUserStream(),
@ -105,15 +107,15 @@ export class WorkspaceService extends Service<WorkspaceServiceEvent> {
{ immediate: true }
)
// Watch for workspace changes and update team collection service accordingly
this.setupTeamCollectionServiceSync()
// Watch for workspace changes and update team collection service and documentation service accordingly
this.setupWorkspaceSync()
}
/**
* Sets up synchronization with team collection service
* This ensures team collections are updated when workspace changes
* Sets up synchronization with team collection service and documentation service
* This ensures team collections and published docs are updated when workspace changes
*/
private setupTeamCollectionServiceSync() {
private setupWorkspaceSync() {
watch(
this._currentWorkspace,
(newWorkspace, oldWorkspace) => {
@ -123,11 +125,18 @@ export class WorkspaceService extends Service<WorkspaceServiceEvent> {
try {
if (newWorkspace.type === "team" && newWorkspace.teamID) {
this.teamCollectionService.changeTeamID(newWorkspace.teamID)
this.documentationService.fetchTeamPublishedDocs(
newWorkspace.teamID
)
} else {
this.teamCollectionService.clearCollections()
this.documentationService.fetchUserPublishedDocs()
}
} catch (error) {
console.error("Failed to sync team collections:", error)
console.error(
"Failed to sync team collections and published docs:",
error
)
}
},
{ immediate: true }