feat: mock server feature enhancements (#5609)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Anwarul Islam 2025-11-25 20:04:54 +06:00 committed by GitHub
parent f834cc87d3
commit 77af577778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2005 additions and 623 deletions

View file

@ -1062,7 +1062,20 @@
"response_headers": "Response Headers",
"response_body": "Response Body",
"log_deleted": "Log deleted successfully",
"description": "Mock servers allow you to simulate API responses based on your collection's example responses."
"description": "Mock servers allow you to simulate API responses based on your collection's example responses.",
"set_in_environment": "Set in environment",
"set_in_environment_hint": "The mock server URL will be automatically added as 'mockUrl' variable in the collection's environment",
"environment_variable_added": "Mock URL added to environment",
"environment_variable_updated": "Mock URL updated in environment",
"environment_created_with_variable": "Environment created with mock URL",
"create_example_collection": "Create example collection",
"create_example_collection_hint": "Create a pet store example collection with sample requests (GET, POST, PUT, DELETE)",
"creating_example_collection": "Creating example collection...",
"failed_to_create_collection": "Failed to create example collection",
"enable_example_collection_hint": "Please enable 'Create example collection' toggle for new collection mode",
"new_collection_name_hint": "The collection will be created with the same name as your mock server",
"existing_collection": "Existing Collection",
"new_collection": "New Collection"
},
"preRequest": {
"javascript_code": "JavaScript Code",

View file

@ -241,15 +241,19 @@ declare module 'vue' {
IconLucideCode2: typeof import('~icons/lucide/code2')['default']
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default']
IconLucideFileText: typeof import('~icons/lucide/file-text')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default']
IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLightbulb: typeof import('~icons/lucide/lightbulb')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
IconLucideLoader2: typeof import('~icons/lucide/loader2')['default']
IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
@ -285,9 +289,12 @@ declare module 'vue' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
MockServerConfigureMockServerModal: typeof import('./components/mockServer/ConfigureMockServerModal.vue')['default']
MockServerCreateMockServer: typeof import('./components/mockServer/CreateMockServer.vue')['default']
MockServerCreateNewMockServerModal: typeof import('./components/mockServer/CreateNewMockServerModal.vue')['default']
MockServerEditMockServer: typeof import('./components/mockServer/EditMockServer.vue')['default']
MockServerLogSection: typeof import('./components/mockServer/LogSection.vue')['default']
MockServerMockServerCreatedInfo: typeof import('./components/mockServer/MockServerCreatedInfo.vue')['default']
MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default']
MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default']
MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default']

View file

@ -13,6 +13,7 @@
import { HoppCollection } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { PropType, Ref, computed, ref } from "vue"
import { transformCollectionForImport } from "~/helpers/collection/collection"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { UrlSource } from "~/helpers/import-export/import/import-sources/UrlSource"
@ -125,7 +126,7 @@ const importToPersonalWorkspace = async (collections: HoppCollection[]) => {
if (currentUser.value) {
try {
const transformedCollection = collections.map((collection) =>
translateToPersonalCollectionFormat(collection)
transformCollectionForImport(collection)
)
const res = await importUserCollectionsFromJSON(
@ -162,52 +163,6 @@ const importToPersonalWorkspace = async (collections: HoppCollection[]) => {
}
}
function translateToTeamCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToTeamCollectionFormat
)
const data = {
auth: x.auth,
headers: x.headers,
variables: x.variables,
description: x.description,
}
const obj = {
...x,
folders,
data,
}
if (x.id) obj.id = x.id
return obj
}
function translateToPersonalCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToPersonalCollectionFormat
)
const data = {
auth: x.auth,
headers: x.headers,
variables: x.variables,
description: x.description,
}
const obj = {
...x,
folders,
data,
}
if (x.id) obj.id = x.id
return obj
}
const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
return E.left({
@ -216,7 +171,7 @@ const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
}
const transformedCollection = collections.map((collection) =>
translateToTeamCollectionFormat(collection)
transformCollectionForImport(collection)
)
const res = await toTeamsImporter(

View file

@ -12,6 +12,7 @@
import { HoppCollection } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { ref } from "vue"
import { transformCollectionForImport } from "~/helpers/collection/collection"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
@ -242,7 +243,7 @@ const handleImportToStore = async (gqlCollections: HoppCollection[]) => {
if (currentUser.value) {
try {
const transformedCollection = gqlCollections.map((collection) =>
translateToPersonalCollectionFormat(collection)
transformCollectionForImport(collection)
)
const res = await importUserCollectionsFromJSON(
@ -282,28 +283,6 @@ const handleImportToStore = async (gqlCollections: HoppCollection[]) => {
}
}
function translateToPersonalCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToPersonalCollectionFormat
)
const data = {
auth: x.auth,
headers: x.headers,
variables: x.variables,
}
const obj = {
...x,
folders,
data,
}
if (x.id) obj.id = x.id
return obj
}
const emit = defineEmits<{
(e: "hide-modal"): () => void
}>()

View file

@ -248,7 +248,7 @@
@hide-modal="showCollectionsRunnerModal = false"
/>
<MockServerCreateMockServer />
<MockServerConfigureMockServerModal />
</div>
</template>

View file

@ -0,0 +1,313 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="
isExistingMockServer
? t('mock_server.mock_server_configuration')
: t('mock_server.create_mock_server')
"
@close="closeModal"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Collection Info (Read-only) -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<div class="text-body text-secondary">
{{ collectionName }}
</div>
</div>
<!-- Existing Mock Server Info -->
<div v-if="isExistingMockServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.mock_server_name") }}
</label>
<div class="text-body text-secondary">
{{ existingMockServer?.name }}
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.base_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
>
{{
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
""
}}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="
copyToClipboard(
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
''
)
"
/>
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("app.status") }}
</label>
<div class="flex items-center space-x-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="
existingMockServer?.isActive
? 'bg-green-600/20 text-green-500 border border-green-600/30'
: 'text-secondary border border-secondaryLight'
"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="
existingMockServer?.isActive
? 'bg-green-400'
: 'bg-secondaryLight'
"
></span>
{{
existingMockServer?.isActive
? t("mockServer.dashboard.active")
: t("mockServer.dashboard.inactive")
}}
</span>
</div>
</div>
</div>
<!-- New Mock Server Form -->
<div v-else class="flex flex-col space-y-6">
<HoppSmartInput
v-model="mockServerName"
v-focus
:label="t('mock_server.mock_server_name')"
input-styles="floating-input"
:disabled="loading"
/>
<div class="flex items-center space-x-4">
<div class="w-48">
<HoppSmartInput
v-model="delayInMsVal"
:label="t('mock_server.delay_ms')"
type="number"
input-styles="floating-input"
:disabled="loading"
/>
</div>
<div class="flex items-center">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic">
{{ t("mock_server.make_public") }}
</HoppSmartToggle>
</div>
</div>
<!-- Hint for private mock servers -->
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
</div>
<!-- Set in Environment Toggle -->
<div class="flex flex-col space-y-2">
<div class="flex items-center">
<HoppSmartToggle
:on="setInEnvironment"
@change="setInEnvironment = !setInEnvironment"
>
{{ t("mock_server.set_in_environment") }}
</HoppSmartToggle>
</div>
<div
v-if="setInEnvironment"
class="w-full text-xs text-secondaryLight"
>
{{ t("mock_server.set_in_environment_hint") }}
</div>
</div>
<MockServerCreatedInfo :mock-server="createdServer" />
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end space-x-2">
<!-- Start/Stop Server Button for existing mock server -->
<HoppButtonPrimary
v-if="isExistingMockServer"
:label="
existingMockServer?.isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:loading="loading"
:icon="existingMockServer?.isActive ? IconSquare : IconPlay"
@click="handleToggleMockServer"
/>
<!-- Create Mock Server Button for new mock server -->
<HoppButtonPrimary
v-else
:label="t('mock_server.create_mock_server')"
:loading="loading"
:disabled="!mockServerName.trim() || !collectionID"
:icon="IconServer"
@click="handleCreateMockServer"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="closeModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { refAutoReset } from "@vueuse/core"
import { computed, ref, watch } from "vue"
import { MockServer } from "~/helpers/backend/graphql"
import { copyToClipboard as copyToClipboardHelper } from "~/helpers/utils/clipboard"
import {
mockServers$,
showCreateMockServerModal$,
} from "~/newstore/mockServers"
import { useMockServer } from "~/composables/useMockServer"
import MockServerCreatedInfo from "~/components/mockServer/MockServerCreatedInfo.vue"
// Icons
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
const t = useI18n()
const toast = useToast()
// Use the composable for shared logic
const { createMockServer, toggleMockServer } = useMockServer()
// Modal state
const modalData = useReadonlyStream(showCreateMockServerModal$, {
show: false,
collectionID: undefined,
collectionName: undefined,
})
const mockServers = useReadonlyStream(mockServers$, [])
// Component state
const mockServerName = ref("")
const loading = ref(false)
const createdServer = ref<MockServer | null>(null)
const delayInMsVal = ref<string>("0")
const isPublic = ref<boolean>(true)
const setInEnvironment = ref<boolean>(true)
// Props computed from modal data
// This modal only shows when collectionID is provided (from collection context menu)
const show = computed(
() => modalData.value.show && !!modalData.value.collectionID
)
const collectionID = computed(() => modalData.value.collectionID)
const collectionName = computed(
() => modalData.value.collectionName || "Unknown Collection"
)
// Find existing mock server for the collection
const existingMockServer = computed(() => {
const collId = collectionID.value
if (!collId) return null
return mockServers.value.find((server) => server.collectionID === collId)
})
const isExistingMockServer = computed(() => !!existingMockServer.value)
// Copy functionality for existing mock server
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const copyToClipboard = (text: string) => {
copyToClipboardHelper(text)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
}
// Create new mock server
const handleCreateMockServer = async () => {
if (!mockServerName.value.trim() || !collectionID.value) {
return
}
loading.value = true
const result = await createMockServer({
mockServerName: mockServerName.value,
collectionID: collectionID.value,
delayInMs: Number(delayInMsVal.value) || 0,
isPublic: isPublic.value,
setInEnvironment: setInEnvironment.value,
collectionName: collectionName.value,
})
loading.value = false
if (result.success && result.server) {
createdServer.value = result.server
}
}
// Toggle mock server active state
const handleToggleMockServer = async () => {
if (!existingMockServer.value) return
loading.value = true
await toggleMockServer(existingMockServer.value as any)
loading.value = false
}
// Close modal
const closeModal = () => {
showCreateMockServerModal$.next({
show: false,
collectionID: undefined,
collectionName: undefined,
})
}
// Reset form when modal opens/closes
watch(show, (newShow) => {
if (newShow) {
mockServerName.value = ""
loading.value = false
delayInMsVal.value = "0"
isPublic.value = true
setInEnvironment.value = true
createdServer.value = null
}
})
</script>

View file

@ -1,543 +0,0 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="
isExistingMockServer
? t('mock_server.mock_server_configuration')
: t('mock_server.create_mock_server')
"
@close="closeModal"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Collection Selector or Info -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<!-- Collection Selector (when no collection is pre-selected) -->
<div v-if="!collectionID && !isExistingMockServer" class="flex">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
class="flex flex-1 !justify-start rounded-none pr-8"
:label="
selectedCollectionName || t('mock_server.select_collection')
"
outline
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartLink
v-for="option in collectionOptions"
:key="option.value"
class="flex flex-1"
:class="{
'opacity-50 cursor-not-allowed': option.disabled,
}"
@click="
() => {
if (!option.disabled) {
selectCollection(option)
hide()
}
}
"
>
<HoppSmartItem
:label="option.label"
:active-info-icon="selectedCollectionID === option.value"
:info-icon="
selectedCollectionID === option.value
? IconCheck
: option.hasMockServer
? IconServer
: null
"
:disabled="option.disabled"
/>
</HoppSmartLink>
<div
v-if="collectionOptions.length === 0"
class="flex items-center justify-center px-4 py-8 text-secondaryLight"
>
{{ t("empty.collections") }}
</div>
</div>
</template>
</tippy>
</div>
<!-- Collection Info (when collection is pre-selected) -->
<div v-else class="text-body text-secondary">
{{ collectionName }}
</div>
</div>
<!-- Existing Mock Server Info -->
<div v-if="isExistingMockServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.mock_server_name") }}
</label>
<div class="text-body text-secondary">
{{ existingMockServer?.name }}
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.base_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
>
{{
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
""
}}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="
copyToClipboard(
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
''
)
"
/>
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("app.status") }}
</label>
<div class="flex items-center space-x-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="
existingMockServer?.isActive
? 'bg-green-600/20 text-green-500 border border-green-600/30'
: 'text-secondary border border-secondaryLight'
"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="
existingMockServer?.isActive
? 'bg-green-400'
: 'bg-secondaryLight'
"
></span>
{{
existingMockServer?.isActive
? t("mockServer.dashboard.active")
: t("mockServer.dashboard.inactive")
}}
</span>
</div>
</div>
</div>
<!-- New Mock Server Form -->
<div v-else class="flex flex-col space-y-6">
<HoppSmartInput
v-model="mockServerName"
v-focus
:label="t('mock_server.mock_server_name')"
input-styles="floating-input"
:disabled="loading"
/>
<div class="flex items-center space-x-4">
<div class="w-48">
<HoppSmartInput
v-model="delayInMsVal"
:label="t('mock_server.delay_ms')"
type="number"
input-styles="floating-input"
:disabled="loading"
/>
</div>
<div class="flex items-center">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic">
{{ t("mock_server.make_public") }}
</HoppSmartToggle>
</div>
</div>
<!-- Hint for private mock servers -->
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
</div>
<!-- Display created server info -->
<div v-if="createdServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.path_based_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
>
{{ createdServer.serverUrlPathBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="
copyToClipboard(createdServer.serverUrlPathBased || '')
"
/>
</div>
</div>
<!-- Subdomain-based URL (May be null) -->
<div
v-if="createdServer.serverUrlDomainBased"
class="flex flex-col space-y-2"
>
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.subdomain_based_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
>
{{ createdServer.serverUrlDomainBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyToClipboard(createdServer.serverUrlDomainBased)"
/>
</div>
<div class="text-xs text-secondaryLight">
<span class="font-medium">{{ t("mock_server.note") }}:</span>
{{ t("mock_server.subdomain_note") }}
</div>
</div>
</div>
</div>
<!-- Help Text -->
<div
class="py-4 px-3 bg-primaryLight rounded-md border border-dividerLight shadow-sm"
>
<p class="text-secondary flex space-x-2 items-start">
<Icon-lucide-info class="svg-icons text-accent" />
<span>
{{ t("mock_server.description") }}
</span>
</p>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end space-x-2">
<!-- Start/Stop Server Button for existing mock server -->
<HoppButtonPrimary
v-if="isExistingMockServer"
:label="
existingMockServer?.isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:loading="loading"
:icon="existingMockServer?.isActive ? IconSquare : IconPlay"
@click="toggleMockServer"
/>
<!-- Create Mock Server Button for new mock server -->
<HoppButtonPrimary
v-else
:label="t('mock_server.create_mock_server')"
:loading="loading"
:disabled="!mockServerName.trim() || !effectiveCollectionID"
:icon="IconServer"
@click="createMockServer"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="closeModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import { MockServer, WorkspaceType } from "~/helpers/backend/graphql"
import {
createMockServer as createMockServerMutation,
updateMockServer,
} from "~/helpers/backend/mutations/MockServer"
import { copyToClipboard as copyToClipboardHelper } from "~/helpers/utils/clipboard"
import { restCollections$ } from "~/newstore/collections"
import {
addMockServer,
mockServers$,
showCreateMockServerModal$,
updateMockServer as updateMockServerInStore,
} from "~/newstore/mockServers"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { WorkspaceService } from "~/services/workspace.service"
// Icons
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
const t = useI18n()
const toast = useToast()
const workspaceService = useService(WorkspaceService)
const teamCollectionsService = useService(TeamCollectionsService)
// Modal state
const modalData = useReadonlyStream(showCreateMockServerModal$, {
show: false,
collectionID: undefined,
collectionName: undefined,
})
const mockServers = useReadonlyStream(mockServers$, [])
const collections = useReadonlyStream(restCollections$, [])
const currentWorkspace = computed(() => workspaceService.currentWorkspace.value)
// Get collections based on current workspace
const availableCollections = computed(() => {
if (currentWorkspace.value.type === "team" && currentWorkspace.value.teamID) {
return teamCollectionsService.collections.value || []
}
return collections.value
})
// Component state
const mockServerName = ref("")
const loading = ref(false)
const showCloseButton = ref(false)
const createdServer = ref<MockServer | null>(null)
const delayInMsVal = ref<string>("0")
const isPublic = ref<boolean>(true)
const selectedCollectionID = ref("")
const selectedCollectionName = ref("")
const tippyActions = ref<TippyComponent | null>(null)
// Props computed from modal data
const show = computed(() => modalData.value.show)
const collectionID = computed(() => modalData.value.collectionID)
const collectionName = computed(() => {
// Prefer name provided by modalData (pre-selected from caller)
if (modalData.value.collectionName) return modalData.value.collectionName
// If user selected a collection inside the modal, use that
if (selectedCollectionName.value) return selectedCollectionName.value
// Try finding the collection from availableCollections using effectiveCollectionID
const id = effectiveCollectionID.value
if (!id) return "Unknown Collection"
const coll = availableCollections.value.find((c: any) => (c as any).id === id)
return (coll as any)?.name || (coll as any)?.title || "Unknown Collection"
})
// Find existing mock server for the effective collection (pre-selected or user-selected)
const existingMockServer = computed(() => {
const collId = effectiveCollectionID.value
if (!collId) return null
return mockServers.value.find((server) => server.collectionID === collId)
})
const isExistingMockServer = computed(() => !!existingMockServer.value)
// Collection options for the selector (only root collections)
const collectionOptions = computed(() => {
return availableCollections.value.map((collection) => {
const collectionId =
currentWorkspace.value.type === "team"
? collection.id
: (collection.id ?? collection._ref_id) // TODO: fix this fallback logic for personal workspaces in the future
const hasMockServer = mockServers.value.some(
(server) => server.collectionID === collectionId
)
return {
label: collection.name || collection.title,
value: collectionId,
collection: collection,
hasMockServer: hasMockServer,
disabled: hasMockServer,
}
})
})
// Get the effective collection ID (either pre-selected or user-selected)
const effectiveCollectionID = computed(() => {
return collectionID.value || selectedCollectionID.value
})
// Collection selection handler
const selectCollection = (option: any) => {
// Prevent selection of collections that already have mock servers
if (option.disabled || option.hasMockServer) {
return
}
selectedCollectionID.value = option.value
selectedCollectionName.value = option.label
}
// Copy functionality
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const copyToClipboard = (text: string) => {
copyToClipboardHelper(text)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
}
// Reset form when modal opens/closes
watch(show, (newShow) => {
if (newShow) {
mockServerName.value = ""
loading.value = false
delayInMsVal.value = "0"
isPublic.value = true
selectedCollectionID.value = ""
selectedCollectionName.value = ""
showCloseButton.value = false
createdServer.value = null
}
})
// Create new mock server
const createMockServer = async () => {
if (!mockServerName.value.trim() || !effectiveCollectionID.value) {
if (!effectiveCollectionID.value) {
toast.error(t("mock_server.select_collection_error"))
}
return
}
loading.value = true
// Determine workspace type and ID based on current workspace
const workspaceType =
currentWorkspace.value.type === "team"
? WorkspaceType.Team
: WorkspaceType.User
const workspaceID =
currentWorkspace.value.type === "team"
? currentWorkspace.value.teamID
: undefined
await pipe(
createMockServerMutation(
mockServerName.value.trim(),
effectiveCollectionID.value,
workspaceType,
workspaceID,
Number(delayInMsVal.value) || 0, // delayInMs
Boolean(isPublic.value) // isPublic
),
TE.match(
(error) => {
// `error` here is the message string produced by the mutation helper.
// Show the backend-provided error message if available, otherwise fallback to generic
toast.error(String(error) || t("error.something_went_wrong"))
loading.value = false
},
(result) => {
toast.success(t("mock_server.mock_server_created"))
// Add the new mock server to the store
addMockServer(result)
// Store the created server data and show close button
createdServer.value = result
showCloseButton.value = true
loading.value = false
// Don't close the modal automatically
}
)
)()
}
// Toggle mock server active state
const toggleMockServer = async () => {
if (!existingMockServer.value) return
loading.value = true
const newActiveState = !existingMockServer.value.isActive
await pipe(
updateMockServer(existingMockServer.value.id, { isActive: newActiveState }),
TE.match(
() => {
toast.error(t("error.something_went_wrong"))
loading.value = false
},
() => {
toast.success(
newActiveState
? t("mock_server.mock_server_started")
: t("mock_server.mock_server_stopped")
)
// Update the mock server in the store
updateMockServerInStore(existingMockServer.value!.id, {
isActive: newActiveState,
})
loading.value = false
}
)
)()
}
// Close modal function
const closeModal = () => {
showCreateMockServerModal$.next({
show: false,
collectionID: undefined,
collectionName: undefined,
})
}
</script>

View file

@ -0,0 +1,548 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('mock_server.create_mock_server')"
@close="closeModal"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Collection Selection Mode (hidden after server is created) -->
<div v-if="!createdServer" class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<div class="flex flex-col space-y-4">
<!-- Radio buttons for collection selection mode -->
<div class="flex space-x-6">
<label class="flex items-center space-x-2 cursor-pointer">
<input
v-model="collectionSelectionMode"
type="radio"
value="existing"
class="w-4 h-4 text-accent border-divider focus:ring-accent"
/>
<span class="text-body text-secondary">
{{ t("mock_server.existing_collection") }}
</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input
v-model="collectionSelectionMode"
type="radio"
value="new"
class="w-4 h-4 text-accent border-divider focus:ring-accent"
/>
<span class="text-body text-secondary">
{{ t("mock_server.new_collection") }}
</span>
</label>
</div>
<!-- Collection dropdown (shown for existing collection mode) -->
<div v-if="collectionSelectionMode === 'existing'" class="flex">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
class="flex flex-1 !justify-start rounded-none pr-8"
:label="
selectedCollectionName ||
t('mock_server.select_collection')
"
outline
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartLink
v-for="option in collectionOptions"
:key="option.value"
class="flex flex-1"
:class="{
'opacity-50 cursor-not-allowed': option.disabled,
}"
@click="
() => {
if (!option.disabled) {
selectCollection(option)
hide()
}
}
"
>
<HoppSmartItem
:label="option.label"
:active-info-icon="
selectedCollectionID === option.value
"
:info-icon="
selectedCollectionID === option.value
? IconCheck
: option.hasMockServer
? IconServer
: null
"
:disabled="option.disabled"
/>
</HoppSmartLink>
<div
v-if="collectionOptions.length === 0"
class="flex items-center justify-center px-4 py-8 text-secondaryLight"
>
{{ t("empty.collections") }}
</div>
</div>
</template>
</tippy>
</div>
</div>
</div>
<!-- Show selected collection name after server is created -->
<div v-else class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<div class="text-body text-secondary">
{{ selectedCollectionName }}
</div>
</div>
<!-- Mock Server Form (Before Creation) -->
<div v-if="!createdServer" class="flex flex-col gap-6">
<div>
<HoppSmartInput
v-model="mockServerName"
v-focus
:label="t('mock_server.mock_server_name')"
input-styles="floating-input"
:disabled="loading"
/>
<!-- Hint for new collection mode -->
<div
v-if="collectionSelectionMode === 'new'"
class="w-full text-xs text-secondaryLight mt-3"
>
{{ t("mock_server.new_collection_name_hint") }}
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-48">
<HoppSmartInput
v-model="delayInMsVal"
:label="t('mock_server.delay_ms')"
type="number"
input-styles="floating-input"
:disabled="loading"
/>
</div>
<div class="flex items-center">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic">
{{ t("mock_server.make_public") }}
</HoppSmartToggle>
</div>
</div>
<!-- Hint for private mock servers -->
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
</div>
<!-- Set in Environment Toggle -->
<div class="flex flex-col space-y-2">
<div class="flex items-center">
<HoppSmartToggle
:on="setInEnvironment"
@change="setInEnvironment = !setInEnvironment"
>
{{ t("mock_server.set_in_environment") }}
</HoppSmartToggle>
</div>
<div
v-if="setInEnvironment"
class="w-full text-xs text-secondaryLight"
>
{{ t("mock_server.set_in_environment_hint") }}
</div>
</div>
<!-- Create Example Collection Toggle (only when "new collection" is selected) -->
<div
v-if="collectionSelectionMode === 'new'"
class="flex flex-col space-y-2"
>
<div class="flex items-center">
<HoppSmartToggle
:on="createExampleCollection"
@change="createExampleCollection = !createExampleCollection"
>
{{ t("mock_server.create_example_collection") }}
</HoppSmartToggle>
</div>
<div
v-if="createExampleCollection"
class="w-full text-xs text-secondaryLight"
>
{{ t("mock_server.create_example_collection_hint") }}
</div>
</div>
</div>
<!-- Mock Server Created Info (After Creation) -->
<div v-else class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.mock_server_name") }}
</label>
<div class="text-body text-secondary">
{{ createdServer.name }}
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("app.status") }}
</label>
<div class="flex items-center space-x-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="
createdServer.isActive
? 'bg-green-600/20 text-green-500 border border-green-600/30'
: 'text-secondary border border-secondaryLight'
"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="
createdServer.isActive
? 'bg-green-400'
: 'bg-secondaryLight'
"
></span>
{{
createdServer.isActive
? t("mockServer.dashboard.active")
: t("mockServer.dashboard.inactive")
}}
</span>
</div>
</div>
<MockServerCreatedInfo :mock-server="createdServer" />
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end space-x-2">
<!-- Start/Stop Server Button (after creation) -->
<HoppButtonPrimary
v-if="createdServer"
:label="
createdServer.isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:loading="loading"
:icon="createdServer.isActive ? IconSquare : IconPlay"
@click="handleToggleMockServer"
/>
<!-- Create Mock Server Button (before creation) -->
<HoppButtonPrimary
v-else
:label="t('mock_server.create_mock_server')"
:loading="loading"
:disabled="
!mockServerName.trim() ||
(!effectiveCollectionID &&
collectionSelectionMode === 'existing') ||
(collectionSelectionMode === 'new' && !createExampleCollection)
"
:icon="IconServer"
@click="handleCreateMockServer"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="closeModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import * as E from "fp-ts/Either"
import { MockServer } from "~/helpers/backend/graphql"
import { showCreateMockServerModal$ } from "~/newstore/mockServers"
import { useMockServer } from "~/composables/useMockServer"
import MockServerCreatedInfo from "~/components/mockServer/MockServerCreatedInfo.vue"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import {
createMockCollectionForTeam,
createMockCollectionForPersonal,
} from "~/helpers/mockServer/exampleMockCollection"
// Icons
import IconCheck from "~icons/lucide/check"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
const t = useI18n()
const toast = useToast()
// Use the composable for shared logic
const {
mockServers,
availableCollections,
createMockServer,
toggleMockServer,
} = useMockServer()
// Services
const workspaceService = useService(WorkspaceService)
// Current workspace
const currentWorkspace = computed(() => workspaceService.currentWorkspace.value)
// Modal state
const modalData = useReadonlyStream(showCreateMockServerModal$, {
show: false,
collectionID: undefined,
collectionName: undefined,
})
// Component state
const mockServerName = ref("")
const loading = ref(false)
const createdServer = ref<MockServer | null>(null)
const delayInMsVal = ref<string>("0")
const isPublic = ref<boolean>(true)
const setInEnvironment = ref<boolean>(true)
const createExampleCollection = ref<boolean>(false)
const selectedCollectionID = ref("")
const selectedCollectionName = ref("")
const tippyActions = ref<TippyComponent | null>(null)
const collectionSelectionMode = ref<"new" | "existing">("existing")
// Props computed from modal data
// This modal only shows when collectionID is NOT provided (from dashboard "New" button)
const show = computed(
() => modalData.value.show && !modalData.value.collectionID
)
// Collection options for the selector (only root collections)
const collectionOptions = computed(() => {
return availableCollections.value.map((collection: any) => {
const collectionId = collection.id ?? collection._ref_id
const hasMockServer = mockServers.value.some(
(server) => server.collectionID === collectionId
)
return {
label: collection.name || collection.title,
value: collectionId,
collection: collection,
hasMockServer: hasMockServer,
disabled: hasMockServer,
}
})
})
// Get the effective collection ID (user-selected)
const effectiveCollectionID = computed(() => {
return selectedCollectionID.value
})
// Get collection name
const collectionName = computed(() => {
if (selectedCollectionName.value) return selectedCollectionName.value
return "Unknown Collection"
})
// Collection selection handler
const selectCollection = (option: any) => {
// Prevent selection of collections that already have mock servers
if (option.disabled || option.hasMockServer) {
return
}
selectedCollectionID.value = option.value
selectedCollectionName.value = option.label
}
// Function to create an example collection and return its ID and name
const createExampleCollectionAndGetID = async (
collectionName: string
): Promise<{
id: string
name: string
}> => {
const workspaceType = currentWorkspace.value.type
if (workspaceType === "personal") {
// For personal workspace
const result = await createMockCollectionForPersonal(collectionName)
if (E.isLeft(result)) {
throw new Error(result.left)
}
return result.right
} else if (workspaceType === "team" && currentWorkspace.value.teamID) {
// For team workspace
const teamID = currentWorkspace.value.teamID
const result = await createMockCollectionForTeam(teamID, collectionName)
if (E.isLeft(result)) {
throw new Error(result.left)
}
// Wait a bit for the subscription to update
await new Promise((resolve) => setTimeout(resolve, 500))
return result.right
}
throw new Error("Unknown workspace type")
}
// Create new mock server
const handleCreateMockServer = async () => {
// Validate mock server name first
if (!mockServerName.value.trim()) {
toast.error(t("mock_server.provide_mock_server_name"))
return
}
// Start loading and show creating message
loading.value = true
// If "new collection" mode is selected, create example collection (if toggle is enabled)
let collectionIDToUse = effectiveCollectionID.value
if (collectionSelectionMode.value === "new") {
if (createExampleCollection.value) {
try {
// Silently create the collection in the background
const newCollection = await createExampleCollectionAndGetID(
mockServerName.value.trim()
)
// Update the selected collection with the actual created collection's ID and name
collectionIDToUse = newCollection.id
selectedCollectionID.value = newCollection.id
selectedCollectionName.value = newCollection.name
} catch (error) {
console.error("Failed to create collection:", error)
// If collection creation fails, stop the entire process
toast.error(t("mock_server.failed_to_create_mock_server"))
loading.value = false
return
}
} else {
// If new collection mode but example collection is not enabled
toast.error(t("mock_server.enable_example_collection_hint"))
loading.value = false
return
}
}
// Validate collection ID
if (!collectionIDToUse) {
toast.error(t("mock_server.select_collection_error"))
loading.value = false
return
}
// Wait a bit more to ensure collection is fully available in the system
await new Promise((resolve) => setTimeout(resolve, 300))
// Now create the mock server
const result = await createMockServer({
mockServerName: mockServerName.value,
collectionID: collectionIDToUse,
delayInMs: Number(delayInMsVal.value) || 0,
isPublic: isPublic.value,
setInEnvironment: setInEnvironment.value,
collectionName: collectionName.value,
})
loading.value = false
if (result.success && result.server) {
createdServer.value = result.server
}
}
// Toggle mock server active state
const handleToggleMockServer = async () => {
if (!createdServer.value) return
loading.value = true
const result = await toggleMockServer(createdServer.value as any)
loading.value = false
// Update the local `createdServer` state with the toggled state
if (result.success && createdServer.value) {
createdServer.value = {
...createdServer.value,
isActive: !createdServer.value.isActive,
}
}
}
// Close modal
const closeModal = () => {
showCreateMockServerModal$.next({
show: false,
collectionID: undefined,
collectionName: undefined,
})
}
// Reset form when modal opens/closes
watch(show, (newShow) => {
if (newShow) {
mockServerName.value = ""
loading.value = false
delayInMsVal.value = "0"
isPublic.value = true
setInEnvironment.value = true
createExampleCollection.value = false
selectedCollectionID.value = ""
selectedCollectionName.value = ""
createdServer.value = null
collectionSelectionMode.value = "existing"
}
})
// Auto-enable example collection toggle when switching to "new" mode
watch(collectionSelectionMode, (newMode) => {
if (newMode === "new") {
createExampleCollection.value = true
}
})
</script>

View file

@ -0,0 +1,93 @@
<template>
<div>
<!-- Display created server info -->
<div v-if="mockServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.path_based_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
>
{{ mockServer.serverUrlPathBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="handleCopy(mockServer.serverUrlPathBased || '')"
/>
</div>
</div>
<!-- Subdomain-based URL (May be null) -->
<div
v-if="mockServer.serverUrlDomainBased"
class="flex flex-col space-y-2"
>
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.subdomain_based_url") }}
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
>
{{ mockServer.serverUrlDomainBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="handleCopy(mockServer.serverUrlDomainBased || '')"
/>
</div>
</div>
</div>
<!-- Help Text -->
<div
class="py-4 px-3 bg-primaryLight rounded-md border border-dividerLight shadow-sm"
>
<p class="text-secondary flex space-x-2 items-start">
<Icon-lucide-info class="svg-icons text-accent" />
<span>
{{ t("mock_server.description") }}
</span>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { refAutoReset } from "@vueuse/core"
import { MockServer } from "~/helpers/backend/graphql"
import { copyToClipboard as copyToClipboardHelper } from "~/helpers/utils/clipboard"
// Icons
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
interface Props {
mockServer: MockServer | null
}
defineProps<Props>()
const t = useI18n()
const toast = useToast()
// Copy functionality
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const handleCopy = (text: string) => {
copyToClipboardHelper(text)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
}
</script>

View file

@ -177,11 +177,7 @@
</div>
<!-- Modals -->
<MockServerCreateMockServer
v-if="showCreateModal"
:show="showCreateModal"
@hide-modal="showCreateModal = false"
/>
<MockServerCreateNewMockServerModal />
<MockServerEditMockServer
v-if="showEditModal && selectedMockServer"
:show="showEditModal"
@ -223,7 +219,6 @@ import {
updateMockServer as updateMockServerInStore,
} from "~/newstore/mockServers"
import MockServerCreateMockServer from "~/components/mockServer/CreateMockServer.vue"
import MockServerEditMockServer from "~/components/mockServer/EditMockServer.vue"
import MockServerLogs from "~/components/mockServer/MockServerLogs.vue"
import {
@ -248,7 +243,6 @@ const toast = useToast()
const colorMode = useColorMode()
const { mockServers } = useMockServerStatus()
const loading = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showLogsModal = ref(false)
const selectedMockServer = ref<MockServer | null>(null)

View file

@ -0,0 +1,302 @@
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { useService } from "dioc/vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { computed } from "vue"
import { MockServer, WorkspaceType } from "~/helpers/backend/graphql"
import {
createMockServer as createMockServerMutation,
updateMockServer,
} from "~/helpers/backend/mutations/MockServer"
import {
createTeamEnvironment,
updateTeamEnvironment,
} from "~/helpers/backend/mutations/TeamEnvironment"
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
import { restCollections$ } from "~/newstore/collections"
import {
addEnvironmentVariable,
createEnvironment,
environments$,
getSelectedEnvironmentIndex,
updateEnvironmentVariable,
} from "~/newstore/environments"
import {
addMockServer,
mockServers$,
updateMockServer as updateMockServerInStore,
} from "~/newstore/mockServers"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { WorkspaceService } from "~/services/workspace.service"
export function useMockServer() {
const t = useI18n()
const toast = useToast()
const workspaceService = useService(WorkspaceService)
const teamCollectionsService = useService(TeamCollectionsService)
const mockServers = useReadonlyStream(mockServers$, [])
const collections = useReadonlyStream(restCollections$, [])
const currentWorkspace = computed(
() => workspaceService.currentWorkspace.value
)
// Get collections based on current workspace
const availableCollections = computed(() => {
if (
currentWorkspace.value.type === "team" &&
currentWorkspace.value.teamID
) {
return teamCollectionsService.collections.value || []
}
return collections.value
})
// Environment management
const myEnvironments = useReadonlyStream(environments$, [])
const teamEnvironmentAdapter = new TeamEnvironmentAdapter(
currentWorkspace.value.type === "team"
? currentWorkspace.value.teamID
: undefined
)
// Function to add mock URL to environment
const addMockUrlToEnvironment = async (
mockUrl: string,
collectionName: string
) => {
const workspaceType = currentWorkspace.value.type
if (workspaceType === "personal") {
// For personal workspace, add to selected environment or create new one
const selectedEnvIndex = getSelectedEnvironmentIndex()
if (selectedEnvIndex.type === "MY_ENV") {
// Check if mockUrl already exists in the environment
const env = myEnvironments.value[selectedEnvIndex.index]
const existingVariableIndex = env.variables.findIndex(
(v) => v.key === "mockUrl"
)
if (existingVariableIndex === -1) {
// Add to existing selected environment
addEnvironmentVariable(selectedEnvIndex.index, {
key: "mockUrl",
initialValue: mockUrl,
currentValue: mockUrl,
secret: false,
})
toast.success(t("mock_server.environment_variable_added"))
} else {
// Update existing mockUrl variable with new value using the store dispatcher
updateEnvironmentVariable(
selectedEnvIndex.index,
existingVariableIndex,
{
key: "mockUrl",
initialValue: mockUrl,
currentValue: mockUrl,
}
)
toast.success(t("mock_server.environment_variable_updated"))
}
} else {
// Create a new environment with the mock URL
const envName = `${collectionName} Environment`
createEnvironment(envName, [
{
key: "mockUrl",
initialValue: mockUrl,
currentValue: mockUrl,
secret: false,
},
])
toast.success(t("mock_server.environment_created_with_variable"))
}
} else if (workspaceType === "team" && currentWorkspace.value.teamID) {
// For team workspace, create a new team environment or update existing one
const teamID = currentWorkspace.value.teamID
// Check if there's an existing team environment for this collection
const teamEnvs = teamEnvironmentAdapter.teamEnvironmentList$.value
const existingEnv = teamEnvs.find((env) =>
env.environment.name.includes(collectionName)
)
if (existingEnv) {
// Update existing environment (add or update the mockUrl variable)
const existingVariableIndex =
existingEnv.environment.variables.findIndex(
(v) => v.key === "mockUrl"
)
let updatedVariables
let successMessage
if (existingVariableIndex === -1) {
// Variable doesn't exist, add it
updatedVariables = [
...existingEnv.environment.variables,
{ key: "mockUrl", value: mockUrl },
]
successMessage = t("mock_server.environment_variable_added")
} else {
// Variable exists, update its value
updatedVariables = existingEnv.environment.variables.map((v, idx) =>
idx === existingVariableIndex ? { ...v, value: mockUrl } : v
)
successMessage = t("mock_server.environment_variable_updated")
}
await pipe(
updateTeamEnvironment(
JSON.stringify(updatedVariables),
existingEnv.id,
existingEnv.environment.name
),
TE.match(
(error) => {
console.error("Failed to update team environment:", error)
toast.error(t("error.something_went_wrong"))
},
() => {
toast.success(successMessage)
}
)
)()
} else {
// Create new team environment
const envName = `${collectionName} Environment`
const variables = [{ key: "mockUrl", value: mockUrl }]
await pipe(
createTeamEnvironment(JSON.stringify(variables), teamID, envName),
TE.match(
(error) => {
console.error("Failed to create team environment:", error)
toast.error(t("error.something_went_wrong"))
},
() => {
toast.success(t("mock_server.environment_created_with_variable"))
}
)
)()
}
}
}
// Create new mock server
const createMockServer = async (params: {
mockServerName: string
collectionID: string
delayInMs: number
isPublic: boolean
setInEnvironment: boolean
collectionName: string
}) => {
const {
mockServerName,
collectionID,
delayInMs,
isPublic,
setInEnvironment,
collectionName,
} = params
if (!mockServerName.trim() || !collectionID) {
if (!collectionID) {
toast.error(t("mock_server.select_collection_error"))
}
return { success: false, server: null }
}
// Determine workspace type and ID based on current workspace
const workspaceType =
currentWorkspace.value.type === "team"
? WorkspaceType.Team
: WorkspaceType.User
const workspaceID =
currentWorkspace.value.type === "team"
? currentWorkspace.value.teamID
: undefined
const result = await pipe(
createMockServerMutation(
mockServerName.trim(),
collectionID,
workspaceType,
workspaceID,
delayInMs,
isPublic
),
TE.match(
(error) => {
toast.error(String(error) || t("error.something_went_wrong"))
return null as MockServer | null
},
(result) => {
toast.success(t("mock_server.mock_server_created"))
// Add the new mock server to the store
addMockServer(result)
return result as MockServer
}
)
)()
if (!result) {
return { success: false, server: null }
}
// Add mock URL to environment if enabled
if (setInEnvironment) {
const mockUrl =
result.serverUrlPathBased || result.serverUrlDomainBased || ""
if (mockUrl) {
await addMockUrlToEnvironment(mockUrl, collectionName)
}
}
return { success: true, server: result }
}
// Toggle mock server active state
const toggleMockServer = async (mockServer: MockServer) => {
const newActiveState = !mockServer.isActive
return await pipe(
updateMockServer(mockServer.id, { isActive: newActiveState }),
TE.match(
() => {
toast.error(t("error.something_went_wrong"))
return { success: false }
},
() => {
toast.success(
newActiveState
? t("mock_server.server_started")
: t("mock_server.server_stopped")
)
// Update the mock server in the store
updateMockServerInStore(mockServer.id, { isActive: newActiveState })
return { success: true }
}
)
)()
}
return {
// State
mockServers,
availableCollections,
currentWorkspace,
// Functions
createMockServer,
toggleMockServer,
addMockUrlToEnvironment,
}
}

View file

@ -0,0 +1,8 @@
mutation CreateRESTRootUserCollection($title: String!, $data: String) {
createRESTRootUserCollection(title: $title, data: $data) {
id
title
data
type
}
}

View file

@ -0,0 +1,15 @@
mutation CreateRESTUserRequest(
$collectionID: ID!
$title: String!
$request: String!
) {
createRESTUserRequest(
collectionID: $collectionID
title: $title
request: $request
) {
id
title
request
}
}

View file

@ -286,3 +286,31 @@ export function getFoldersByPath(
return currentCollection.folders
}
/**
* Transforms a collection to the format expected by team or personal collections.
* Extracts auth, headers, and variables into a data object and recursively processes folders.
* @param collection The collection to transform
* @returns The transformed collection
*/
export function transformCollectionForImport(collection: any): any {
const folders: any[] = (collection.folders ?? []).map(
transformCollectionForImport
)
const data = {
auth: collection.auth,
headers: collection.headers,
variables: collection.variables,
}
const obj = {
...collection,
folders,
data,
}
if (collection.id) obj.id = collection.id
return obj
}

View file

@ -0,0 +1,327 @@
import {
HoppCollection,
HoppRESTRequest,
makeCollection,
makeRESTRequest,
} from "@hoppscotch/data"
import { uniqueID } from "~/helpers/utils/uniqueID"
const MOCK_URL_VAR = "<<mockUrl>>"
/**
* Returns a JSON string of the Pet Store example collection for import
* @returns JSON string representation of the collection
*/
export function getPetStoreExampleJSON(): string {
const collection = createExamplePetStoreCollection()
return JSON.stringify(collection)
}
/**
* Creates an example Pet Store collection with 4 requests (GET, POST, PUT, DELETE)
* @param collectionName The name for the collection
* @returns A HoppCollection object with example pet store requests
*/
export function createExamplePetStoreCollection(
collectionName: string = "Pet Store Mock Server"
): HoppCollection {
const requests: HoppRESTRequest[] = [
// GET - List all pets
makeRESTRequest({
id: uniqueID(),
name: "Get All Pets",
method: "GET",
endpoint: `${MOCK_URL_VAR}/pets`,
params: [],
headers: [],
auth: {
authType: "inherit",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
requestVariables: [],
responses: {
[uniqueID()]: {
status: 200,
body: JSON.stringify(
[
{
id: 1,
name: "Buddy",
species: "Dog",
breed: "Golden Retriever",
age: 3,
status: "available",
},
{
id: 2,
name: "Whiskers",
species: "Cat",
breed: "Siamese",
age: 2,
status: "available",
},
{
id: 3,
name: "Charlie",
species: "Dog",
breed: "Beagle",
age: 4,
status: "adopted",
},
],
null,
2
),
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
],
},
},
}),
// GET - Get a single pet by ID
makeRESTRequest({
id: uniqueID(),
name: "Get Pet by ID",
method: "GET",
endpoint: `${MOCK_URL_VAR}/pets/1`,
params: [],
headers: [],
auth: {
authType: "inherit",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
requestVariables: [],
responses: {
[uniqueID()]: {
status: 200,
body: JSON.stringify(
{
id: 1,
name: "Buddy",
species: "Dog",
breed: "Golden Retriever",
age: 3,
status: "available",
description: "Friendly and energetic golden retriever",
vaccinated: true,
neutered: true,
},
null,
2
),
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
],
},
},
}),
// POST - Create a new pet
makeRESTRequest({
id: uniqueID(),
name: "Create New Pet",
method: "POST",
endpoint: `${MOCK_URL_VAR}/pets`,
params: [],
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
],
auth: {
authType: "inherit",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: "application/json",
body: JSON.stringify(
{
name: "Max",
species: "Dog",
breed: "Labrador",
age: 2,
status: "available",
description: "Playful labrador looking for a home",
vaccinated: true,
neutered: false,
},
null,
2
),
},
requestVariables: [],
responses: {
[uniqueID()]: {
status: 201,
body: JSON.stringify(
{
id: 4,
name: "Max",
species: "Dog",
breed: "Labrador",
age: 2,
status: "available",
description: "Playful labrador looking for a home",
vaccinated: true,
neutered: false,
createdAt: new Date().toISOString(),
},
null,
2
),
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
{
key: "Location",
value: "/pets/4",
active: true,
description: "",
},
],
},
},
}),
// PUT - Update an existing pet
makeRESTRequest({
id: uniqueID(),
name: "Update Pet",
method: "PUT",
endpoint: `${MOCK_URL_VAR}/pets/1`,
params: [],
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
],
auth: {
authType: "inherit",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: "application/json",
body: JSON.stringify(
{
name: "Buddy",
species: "Dog",
breed: "Golden Retriever",
age: 4,
status: "adopted",
description: "Friendly golden retriever - Now adopted!",
vaccinated: true,
neutered: true,
},
null,
2
),
},
requestVariables: [],
responses: {
[uniqueID()]: {
status: 200,
body: JSON.stringify(
{
id: 1,
name: "Buddy",
species: "Dog",
breed: "Golden Retriever",
age: 4,
status: "adopted",
description: "Friendly golden retriever - Now adopted!",
vaccinated: true,
neutered: true,
updatedAt: new Date().toISOString(),
},
null,
2
),
headers: [
{
key: "Content-Type",
value: "application/json",
active: true,
description: "",
},
],
},
},
}),
// DELETE - Delete a pet
makeRESTRequest({
id: uniqueID(),
name: "Delete Pet",
method: "DELETE",
endpoint: `${MOCK_URL_VAR}/pets/3`,
params: [],
headers: [],
auth: {
authType: "inherit",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
requestVariables: [],
responses: {
[uniqueID()]: {
status: 204,
body: "",
headers: [],
},
},
}),
]
return makeCollection({
name: collectionName,
folders: [],
requests,
auth: {
authType: "inherit",
authActive: true,
},
headers: [],
})
}

View file

@ -0,0 +1,343 @@
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import * as E from "fp-ts/Either"
import {
HoppRESTRequest,
RESTReqSchemaVersion,
makeCollection,
} from "@hoppscotch/data"
import { createNewRootCollection } from "~/helpers/backend/mutations/TeamCollection"
import { createRequestInCollection } from "~/helpers/backend/mutations/TeamRequest"
import { runMutation } from "~/helpers/backend/GQLClient"
import {
CreateRestRootUserCollectionDocument,
CreateRestRootUserCollectionMutation,
CreateRestRootUserCollectionMutationVariables,
CreateRestUserRequestDocument,
CreateRestUserRequestMutation,
CreateRestUserRequestMutationVariables,
} from "~/helpers/backend/graphql"
import { addRESTCollection } from "~/newstore/collections"
/**
* Get example REST requests for mock server collection
*/
export function getExampleMockRequests(): HoppRESTRequest[] {
const petBody = JSON.stringify(
{
id: 1,
category: {
id: 1,
name: "string",
},
name: "doggie",
photoUrls: ["string"],
tags: [],
status: "available",
},
null,
2
)
const oauthAuth = {
authType: "oauth-2" as const,
authActive: true,
grantTypeInfo: {
authEndpoint: "<<mockUrl>>/oauth/authorize",
clientID: "",
grantType: "IMPLICIT" as const,
scopes: "write:pets read:pets",
token: "",
authRequestParams: [],
refreshRequestParams: [],
},
addTo: "HEADERS" as const,
}
const requests: HoppRESTRequest[] = [
// addPet request
{
v: RESTReqSchemaVersion,
name: "addPet",
method: "POST",
endpoint: "<<mockUrl>>/v2/pet",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
body: {
contentType: "application/json",
body: petBody,
},
auth: oauthAuth,
requestVariables: [],
responses: {},
},
// updatePet request
{
v: RESTReqSchemaVersion,
name: "updatePet",
method: "PUT",
endpoint: "<<mockUrl>>/v2/pet",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
body: {
contentType: "application/json",
body: petBody,
},
auth: oauthAuth,
requestVariables: [],
responses: {},
},
// findByStatus request
{
v: RESTReqSchemaVersion,
name: "findByStatus",
method: "GET",
endpoint: "<<mockUrl>>/v2/pet/findByStatus",
params: [
{
key: "status",
value: "available",
active: true,
description: "",
},
],
headers: [],
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
auth: oauthAuth,
requestVariables: [],
responses: {},
},
// getPetById request
{
v: RESTReqSchemaVersion,
name: "getPetById",
method: "GET",
endpoint: "<<mockUrl>>/v2/pet/1",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
auth: {
authType: "api-key",
authActive: true,
key: "api_key",
value: "",
addTo: "HEADERS",
},
requestVariables: [],
responses: {},
},
// updatePetWithForm request
{
v: RESTReqSchemaVersion,
name: "updatePetWithForm",
method: "POST",
endpoint: "<<mockUrl>>/v2/pet/1",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
body: {
contentType: "application/x-www-form-urlencoded",
body: "name=doggie&status=available",
},
auth: oauthAuth,
requestVariables: [],
responses: {},
},
// deletePet request
{
v: RESTReqSchemaVersion,
name: "deletePet",
method: "DELETE",
endpoint: "<<mockUrl>>/v2/pet/1",
params: [],
headers: [
{
key: "api_key",
value: "",
active: true,
description: "",
},
],
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
auth: oauthAuth,
requestVariables: [],
responses: {},
},
]
return requests
}
/**
* Create a mock collection for team workspace
*/
export async function createMockCollectionForTeam(
teamID: string,
collectionName: string
): Promise<E.Either<string, { id: string; name: string }>> {
// Create the root collection
const collectionResult = await pipe(
createNewRootCollection(collectionName, teamID),
TE.match(
(error) => E.left(`Failed to create collection: ${error}`),
(collection) => E.right(collection)
)
)()
if (E.isLeft(collectionResult)) {
return collectionResult
}
const collectionID = collectionResult.right.createRootCollection.id
// Create requests in the collection
const requests = getExampleMockRequests()
for (const request of requests) {
const requestResult = await pipe(
createRequestInCollection(collectionID, {
request: JSON.stringify(request),
teamID,
title: request.name,
}),
TE.match(
(error) => E.left(`Failed to create request: ${error}`),
(req) => E.right(req)
)
)()
if (E.isLeft(requestResult)) {
// Log error but continue with other requests
console.error(
"Failed to create request:",
request.name,
requestResult.left
)
}
}
return E.right({
id: collectionID,
name: collectionName,
})
}
/**
* Create a mock collection for personal workspace
* Uses backend GraphQL mutations to create the collection with proper backend ID
*/
export async function createMockCollectionForPersonal(
collectionName: string
): Promise<E.Either<string, { id: string; name: string }>> {
// Prepare collection data
const data = {
auth: {
authType: "inherit" as const,
authActive: true,
},
headers: [],
variables: [],
}
// Create the root collection using GraphQL mutation
const collectionResult = await pipe(
runMutation<
CreateRestRootUserCollectionMutation,
CreateRestRootUserCollectionMutationVariables,
""
>(CreateRestRootUserCollectionDocument, {
title: collectionName,
data: JSON.stringify(data),
}),
TE.match(
(error) => E.left(`Failed to create collection: ${error}`),
(response) => E.right(response)
)
)()
if (E.isLeft(collectionResult)) {
return collectionResult
}
// Extract the collection ID from the response
const collectionID = collectionResult.right.createRESTRootUserCollection.id
// Create requests in the collection using GraphQL mutation
const requests = getExampleMockRequests()
const createdRequests: HoppRESTRequest[] = []
for (const request of requests) {
const requestResult = await pipe(
runMutation<
CreateRestUserRequestMutation,
CreateRestUserRequestMutationVariables,
""
>(CreateRestUserRequestDocument, {
collectionID,
title: request.name,
request: JSON.stringify(request),
}),
TE.match(
(error) => E.left(`Failed to create request: ${error}`),
(req) => E.right(req)
)
)()
if (E.isLeft(requestResult)) {
// Log error but continue with other requests
console.error(
"Failed to create request:",
request.name,
requestResult.left
)
} else {
// Add the request ID to the created request
const createdRequest = {
...request,
id: requestResult.right.createRESTUserRequest.id,
}
createdRequests.push(createdRequest)
}
}
// Create a HoppCollection object and add it to the store immediately
const collection = makeCollection({
name: collectionName,
folders: [],
requests: createdRequests,
auth: data.auth,
headers: data.headers,
variables: data.variables,
})
// Add the backend ID to the collection
collection.id = collectionID
// Add the collection to the store so it's visible immediately
addRESTCollection(collection)
return E.right({
id: collectionID,
name: collectionName,
})
}