feat: CLI collection runner command generation UI flow (#4141)

Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
James George
2024-06-27 06:47:56 -07:00
committed by GitHub
parent a9afb17dc0
commit 3b70668162
11 changed files with 586 additions and 99 deletions

View File

@@ -191,7 +191,8 @@
"save_as": "Save as", "save_as": "Save as",
"save_to_collection": "Save to Collection", "save_to_collection": "Save to Collection",
"select": "Select a Collection", "select": "Select a Collection",
"select_location": "Select location" "select_location": "Select location",
"details": "Details"
}, },
"confirm": { "confirm": {
"close_unsaved_tab": "Are you sure you want to close this tab?", "close_unsaved_tab": "Are you sure you want to close this tab?",
@@ -309,7 +310,9 @@
"value": "Value", "value": "Value",
"variable": "Variable", "variable": "Variable",
"variables": "Variables", "variables": "Variables",
"variable_list": "Variable List" "variable_list": "Variable List",
"properties": "Environment Properties",
"details": "Details"
}, },
"error": { "error": {
"authproviders_load_error": "Unable to load auth providers", "authproviders_load_error": "Unable to load auth providers",
@@ -1062,5 +1065,18 @@
"generate_new_token": "Generate new token", "generate_new_token": "Generate new token",
"generate_modal_title": "New Personal Access Token", "generate_modal_title": "New Personal Access Token",
"deletion_success": "The access token {label} has been deleted" "deletion_success": "The access token {label} has been deleted"
},
"collection_runner": {
"collection_id": "Collection ID",
"environment_id": "Environment ID",
"cli_collection_id_description": "This collection ID will be used by the CLI collection runner for Hoppscotch.",
"cli_environment_id_description": "This environment ID will be used by the CLI collection runner for Hoppscotch.",
"include_active_environment": "Include active environment:",
"cli": "CLI",
"ui": "Runner (coming soon)",
"cli_command_generation_description_cloud": "Copy the below command and run it from the CLI. Please specify a personal access token.",
"cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.",
"cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.",
"run_collection": "Run collection"
} }
} }

View File

@@ -61,6 +61,7 @@ declare module 'vue' {
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default'] CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsProperties: typeof import('./components/collections/Properties.vue')['default'] CollectionsProperties: typeof import('./components/collections/Properties.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default'] CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsRunner: typeof import('./components/collections/Runner.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default'] CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
@@ -72,6 +73,7 @@ declare module 'vue' {
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default'] EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default'] EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default'] EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default']
EnvironmentsProperties: typeof import('./components/environments/Properties.vue')['default']
EnvironmentsSelector: typeof import('./components/environments/Selector.vue')['default'] EnvironmentsSelector: typeof import('./components/environments/Selector.vue')['default']
EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default'] EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default']
EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default'] EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default']

View File

@@ -33,7 +33,7 @@
dropItemID = '' dropItemID = ''
} }
" "
@contextmenu.prevent="options?.tippy.show()" @contextmenu.prevent="options?.tippy?.show()"
> >
<div <div
class="flex min-w-0 flex-1 items-center justify-center" class="flex min-w-0 flex-1 items-center justify-center"
@@ -73,6 +73,14 @@
class="hidden group-hover:inline-flex" class="hidden group-hover:inline-flex"
@click="emit('add-folder')" @click="emit('add-folder')"
/> />
<HoppButtonSecondary
v-if="collectionsType === 'team-collections'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlaySquare"
:title="t('collection_runner.run_collection')"
class="hidden group-hover:inline-flex"
@click="emit('run-collection', props.id)"
/>
<span> <span>
<tippy <tippy
ref="options" ref="options"
@@ -97,6 +105,7 @@
@keyup.delete="deleteAction?.$el.click()" @keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()" @keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()" @keyup.p="propertiesAction?.$el.click()"
@keyup.t="runCollectionAction?.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
@@ -172,6 +181,19 @@
} }
" "
/> />
<HoppSmartItem
v-if="collectionsType === 'team-collections'"
ref="runCollectionAction"
:icon="IconPlaySquare"
:label="t('collection_runner.run_collection')"
:shortcut="['T']"
@click="
() => {
emit('run-collection', props.id)
hide()
}
"
/>
</div> </div>
</template> </template>
</tippy> </tippy>
@@ -197,26 +219,27 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import IconSettings2 from "~icons/lucide/settings-2"
import { ref, computed, watch } from "vue"
import { HoppCollection } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { HoppCollection } from "@hoppscotch/data"
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy" import { TippyComponent } from "vue-tippy"
import { useReadonlyStream } from "~/composables/stream"
import { TeamCollection } from "~/helpers/teams/TeamCollection" import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { import {
changeCurrentReorderStatus, changeCurrentReorderStatus,
currentReorderingStatus$, currentReorderingStatus$,
} from "~/newstore/reordering" } from "~/newstore/reordering"
import { useReadonlyStream } from "~/composables/stream" import IconCheckCircle from "~icons/lucide/check-circle"
import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit"
import IconFilePlus from "~icons/lucide/file-plus"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconPlaySquare from "~icons/lucide/play-square"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
type CollectionType = "my-collections" | "team-collections" type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder" type FolderType = "collection" | "folder"
@@ -267,16 +290,18 @@ const emit = defineEmits<{
(event: "dragging", payload: boolean): void (event: "dragging", payload: boolean): void
(event: "update-collection-order", payload: DataTransfer): void (event: "update-collection-order", payload: DataTransfer): void
(event: "update-last-collection-order", payload: DataTransfer): void (event: "update-last-collection-order", payload: DataTransfer): void
(event: "run-collection", collectionID: string): void
}>() }>()
const tippyActions = ref<TippyComponent | null>(null) const tippyActions = ref<HTMLDivElement | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null) const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null) const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null) const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null) const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null) const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null) const options = ref<TippyComponent | null>(null)
const propertiesAction = ref<TippyComponent | null>(null) const propertiesAction = ref<HTMLButtonElement | null>(null)
const runCollectionAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false) const dragging = ref(false)
const ordering = ref(false) const ordering = ref(false)
@@ -319,7 +344,7 @@ watch(
() => props.exportLoading, () => props.exportLoading,
(val) => { (val) => {
if (!val) { if (!val) {
options.value!.tippy.hide() options.value!.tippy?.hide()
} }
} }
) )

View File

@@ -12,7 +12,7 @@
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10 !-py-4" styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10 !-py-4"
render-inactive-tabs render-inactive-tabs
> >
<HoppSmartTab :id="'headers'" :label="`${t('tab.headers')}`"> <HoppSmartTab id="headers" :label="`${t('tab.headers')}`">
<HttpHeaders <HttpHeaders
v-model="editableCollection" v-model="editableCollection"
:is-collection-property="true" :is-collection-property="true"
@@ -24,15 +24,13 @@
{{ t("helpers.collection_properties_header") }} {{ t("helpers.collection_properties_header") }}
</div> </div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab
:id="'authorization'" <HoppSmartTab id="authorization" :label="`${t('tab.authorization')}`">
:label="`${t('tab.authorization')}`"
>
<HttpAuthorization <HttpAuthorization
v-model="editableCollection.auth" v-model="editableCollection.auth"
:is-collection-property="true" :is-collection-property="true"
:is-root-collection="editingProperties?.isRootCollection" :is-root-collection="editingProperties.isRootCollection"
:inherited-properties="editingProperties?.inheritedProperties" :inherited-properties="editingProperties.inheritedProperties"
:source="source" :source="source"
/> />
<div <div
@@ -42,23 +40,77 @@
{{ t("helpers.collection_properties_authorization") }} {{ t("helpers.collection_properties_authorization") }}
</div> </div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab
v-if="showDetails"
:id="'details'"
:label="t('collection.details')"
>
<div
class="flex flex-shrink-0 items-center justify-between border-b border-dividerLight bg-primary pl-4"
>
<span>{{ t("collection_runner.collection_id") }}</span>
<!-- TODO: Make it point to the section about accessing collections via the ID -->
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/clients/cli"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</div>
<div class="p-4">
<div
class="flex items-center justify-between py-2 px-4 rounded-md bg-primaryLight select-text"
>
<div class="text-secondaryDark">
{{ editingProperties.path }}
</div>
<HoppButtonSecondary
filled
:icon="copyIcon"
@click="copyCollectionID"
/>
</div>
</div>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
>
<icon-lucide-info class="svg-icons mr-2" />
{{ t("collection_runner.cli_collection_id_description") }}
</div>
</HoppSmartTab>
</HoppSmartTabs> </HoppSmartTabs>
</template> </template>
<template #footer> <template #footer>
<span class="flex space-x-2"> <div class="flex gap-x-2">
<HoppButtonPrimary <HoppButtonPrimary
v-if="activeTabIsDetails"
:label="t('action.copy')"
:icon="copyIcon"
outline
filled
@click="copyCollectionID"
/>
<HoppButtonPrimary
v-else
:label="t('action.save')" :label="t('action.save')"
:loading="loadingState" :loading="loadingState"
outline outline
@click="saveEditedCollection" @click="saveEditedCollection"
/> />
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.cancel')" :label="activeTabIsDetails ? t('action.close') : t('action.cancel')"
outline outline
filled filled
@click="hideModal" @click="hideModal"
/> />
</span> </div>
</template> </template>
</HoppSmartModal> </HoppSmartModal>
</template> </template>
@@ -72,13 +124,18 @@ import {
HoppRESTAuth, HoppRESTAuth,
HoppRESTHeaders, HoppRESTHeaders,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core" import { refAutoReset, useVModel } from "@vueuse/core"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { clone } from "lodash-es" import { clone } from "lodash-es"
import { ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { PersistenceService } from "~/services/persistence" import { PersistenceService } from "~/services/persistence"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconHelpCircle from "~icons/lucide/help-circle"
const persistenceService = useService(PersistenceService) const persistenceService = useService(PersistenceService)
const t = useI18n() const t = useI18n()
@@ -93,18 +150,21 @@ export type EditingProperties = {
type HoppCollectionAuth = HoppRESTAuth | HoppGQLAuth type HoppCollectionAuth = HoppRESTAuth | HoppGQLAuth
type HoppCollectionHeaders = HoppRESTHeaders | GQLHeader[] type HoppCollectionHeaders = HoppRESTHeaders | GQLHeader[]
const toast = useToast()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
show: boolean show: boolean
loadingState: boolean loadingState: boolean
editingProperties: EditingProperties | null editingProperties: EditingProperties
source: "REST" | "GraphQL" source: "REST" | "GraphQL"
modelValue: string modelValue: string
showDetails: boolean
}>(), }>(),
{ {
show: false, show: false,
loadingState: false, loadingState: false,
editingProperties: null, showDetails: false,
} }
) )
@@ -128,15 +188,22 @@ const editableCollection = ref<{
}, },
}) })
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const activeTabIsDetails = computed(() => activeTab.value === "details")
watch( watch(
editableCollection, editableCollection,
(updatedEditableCollection) => { (updatedEditableCollection) => {
if (props.show && props.editingProperties) { if (props.show && props.editingProperties) {
const unsavedCollectionProperties: EditingProperties = { const unsavedCollectionProperties: EditingProperties = {
collection: updatedEditableCollection, collection: updatedEditableCollection,
isRootCollection: props.editingProperties?.isRootCollection ?? false, isRootCollection: props.editingProperties.isRootCollection ?? false,
path: props.editingProperties?.path, path: props.editingProperties.path,
inheritedProperties: props.editingProperties?.inheritedProperties, inheritedProperties: props.editingProperties.inheritedProperties,
} }
persistenceService.setLocalConfig( persistenceService.setLocalConfig(
"unsaved_collection_properties", "unsaved_collection_properties",
@@ -154,7 +221,13 @@ const activeTab = useVModel(props, "modelValue", emit)
watch( watch(
() => props.show, () => props.show,
(show) => { (show) => {
if (show && props.editingProperties?.collection) { // `Details` tab doesn't exist for personal workspace, hence switching to the `Headers` tab
// The modal can appear empty while switching from a team workspace with `Details` as the active tab
if (activeTab.value === "details" && !props.showDetails) {
activeTab.value = "headers"
}
if (show && props.editingProperties.collection) {
editableCollection.value.auth = clone( editableCollection.value.auth = clone(
props.editingProperties.collection.auth as HoppCollectionAuth props.editingProperties.collection.auth as HoppCollectionAuth
) )
@@ -194,4 +267,11 @@ const hideModal = () => {
persistenceService.removeLocalConfig("unsaved_collection_properties") persistenceService.removeLocalConfig("unsaved_collection_properties")
emit("hide-modal") emit("hide-modal")
} }
const copyCollectionID = () => {
copyToClipboard(props.editingProperties.path)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
</script> </script>

View File

@@ -0,0 +1,149 @@
<template>
<HoppSmartModal
dialog
:title="t('collection_runner.run_collection')"
@close="closeModal"
>
<template #body>
<HoppSmartTabs v-model="activeTab">
<HoppSmartTab id="cli" :label="t('collection_runner.cli')">
<div class="space-y-4 p-4">
<p
class="p-4 mb-4 border rounded-md text-amber-500 border-amber-600"
>
{{ cliCommandGenerationDescription }}
</p>
<div v-if="environmentID" class="flex gap-x-2 items-center">
<HoppSmartCheckbox
:on="includeEnvironmentID"
@change="toggleIncludeEnvironment"
/>
<span class="truncate"
>{{ t("collection_runner.include_active_environment") }}
<span class="text-secondaryDark">{{
activeEnvironment
}}</span></span
>
</div>
<div
class="p-4 rounded-md bg-primaryLight text-secondaryDark select-text"
>
{{ generatedCLICommand }}
</div>
</div>
</HoppSmartTab>
<HoppSmartTab id="runner" disabled :label="t('collection_runner.ui')" />
</HoppSmartTabs>
</template>
<template #footer>
<div class="flex space-x-2">
<HoppButtonPrimary
:label="`${t('action.copy')}`"
:icon="copyIcon"
outline
@click="copyCLICommandToClipboard"
/>
<HoppButtonSecondary
:label="`${t('action.close')}`"
outline
filled
@click="closeModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { refAutoReset } from "@vueuse/core"
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { SelectedEnvironmentIndex } from "~/newstore/environments"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
collectionID: string
environmentID?: string | null
selectedEnvironmentIndex: SelectedEnvironmentIndex
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const includeEnvironmentID = ref(false)
const activeTab = ref("cli")
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const activeEnvironment = computed(() => {
const selectedEnv = props.selectedEnvironmentIndex
if (selectedEnv.type === "TEAM_ENV") {
return selectedEnv.environment.name
}
return null
})
const isCloudInstance = window.location.hostname === "hoppscotch.io"
const cliCommandGenerationDescription = computed(() => {
if (isCloudInstance) {
return t("collection_runner.cli_command_generation_description_cloud")
}
if (import.meta.env.VITE_BACKEND_API_URL) {
return t("collection_runner.cli_command_generation_description_sh")
}
return t(
"collection_runner.cli_command_generation_description_sh_with_server_url_placeholder"
)
})
const generatedCLICommand = computed(() => {
const { collectionID, environmentID } = props
const environmentFlag =
includeEnvironmentID.value && environmentID ? `-e ${environmentID}` : ""
const serverUrl = import.meta.env.VITE_BACKEND_API_URL?.endsWith("/v1")
? // Removing `/v1` prefix
import.meta.env.VITE_BACKEND_API_URL.slice(0, -3)
: "<server_url>"
const serverFlag = isCloudInstance ? "" : `--server ${serverUrl}`
return `hopp test ${collectionID} ${environmentFlag} --token <access_token> ${serverFlag}`
})
const toggleIncludeEnvironment = () => {
includeEnvironmentID.value = !includeEnvironmentID.value
}
const copyCLICommandToClipboard = () => {
copyToClipboard(generatedCLICommand.value)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const closeModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -129,6 +129,7 @@
}) })
} }
" "
@run-collection="emit('run-collection', $event)"
@click=" @click="
() => { () => {
handleCollectionClick({ handleCollectionClick({
@@ -218,6 +219,7 @@
}) })
} }
" "
@run-collection="emit('run-collection', $event)"
@click=" @click="
() => { () => {
handleCollectionClick({ handleCollectionClick({
@@ -586,6 +588,7 @@ const emit = defineEmits<{
(event: "expand-team-collection", payload: string): void (event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void (event: "display-modal-add"): void
(event: "display-modal-import-export"): void (event: "display-modal-import-export"): void
(event: "run-collection", collectionID: string): void
}>() }>()
const getPath = (path: string) => { const getPath = (path: string) => {

View File

@@ -94,6 +94,7 @@
@display-modal-add="displayModalAdd(true)" @display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)" @display-modal-import-export="displayModalImportExport(true)"
@collection-click="handleCollectionClick" @collection-click="handleCollectionClick"
@run-collection="runCollectionHandler"
/> />
<div <div
class="py-15 hidden flex-1 flex-col items-center justify-center bg-primaryDark px-4 text-secondaryLight" class="py-15 hidden flex-1 flex-col items-center justify-center bg-primaryDark px-4 text-secondaryLight"
@@ -164,42 +165,26 @@
v-model="collectionPropertiesModalActiveTab" v-model="collectionPropertiesModalActiveTab"
:show="showModalEditProperties" :show="showModalEditProperties"
:editing-properties="editingProperties" :editing-properties="editingProperties"
:show-details="collectionsType.type === 'team-collections'"
source="REST" source="REST"
@hide-modal="displayModalEditProperties(false)" @hide-modal="displayModalEditProperties(false)"
@set-collection-properties="setCollectionProperties" @set-collection-properties="setCollectionProperties"
/> />
<!-- `selectedCollectionID` is guaranteed to be a string when `showCollectionsRunnerModal` is `true` -->
<CollectionsRunner
v-if="showCollectionsRunnerModal"
:collection-i-d="selectedCollectionID!"
:environment-i-d="activeEnvironmentID"
:selected-environment-index="selectedEnvironmentIndex"
@hide-modal="showCollectionsRunnerModal = false"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, PropType, ref, watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { Picked } from "~/helpers/types/HoppPicked" import { useToast } from "@composables/toast"
import { useReadonlyStream } from "~/composables/stream"
import { useLocalState } from "~/newstore/localstate"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import {
addRESTCollection,
addRESTFolder,
editRESTCollection,
editRESTFolder,
editRESTRequest,
moveRESTRequest,
removeRESTCollection,
removeRESTFolder,
removeRESTRequest,
restCollections$,
saveRESTRequestAs,
updateRESTRequestOrder,
updateRESTCollectionOrder,
moveRESTFolder,
navigateToFolderWithIndexPath,
restCollectionStore,
cascadeParentCollectionForHeaderAuth,
} from "~/newstore/collections"
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
import { import {
HoppCollection, HoppCollection,
HoppRESTAuth, HoppRESTAuth,
@@ -207,51 +192,82 @@ import {
HoppRESTRequest, HoppRESTRequest,
makeCollection, makeCollection,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { cloneDeep, debounce, isEqual } from "lodash-es" import { cloneDeep, debounce, isEqual } from "lodash-es"
import { PropType, computed, nextTick, onMounted, ref, watch } from "vue"
import { useReadonlyStream, useStream } from "~/composables/stream"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
import { import {
createNewRootCollection, getCompleteCollectionTree,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import {
createChildCollection, createChildCollection,
createNewRootCollection,
deleteCollection, deleteCollection,
moveRESTTeamCollection, moveRESTTeamCollection,
updateOrderRESTTeamCollection, updateOrderRESTTeamCollection,
updateTeamCollection, updateTeamCollection,
} from "~/helpers/backend/mutations/TeamCollection" } from "~/helpers/backend/mutations/TeamCollection"
import { import {
updateTeamRequest,
createRequestInCollection, createRequestInCollection,
deleteTeamRequest, deleteTeamRequest,
moveRESTTeamRequest, moveRESTTeamRequest,
updateOrderRESTTeamRequest, updateOrderRESTTeamRequest,
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest" } from "~/helpers/backend/mutations/TeamRequest"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { Collection as NodeCollection } from "./MyCollections.vue"
import { import {
getCompleteCollectionTree, getFoldersByPath,
teamCollToHoppRESTColl, resetTeamRequestsContext,
} from "~/helpers/backend/helpers" resolveSaveContextOnCollectionReorder,
import { platform } from "~/platform" updateInheritedPropertiesForAffectedRequests,
updateSaveContextForAffectedRequests,
} from "~/helpers/collection/collection"
import { import {
getRequestsByPath, getRequestsByPath,
resolveSaveContextOnRequestReorder, resolveSaveContextOnRequestReorder,
} from "~/helpers/collection/request" } from "~/helpers/collection/request"
import { import { TeamCollection } from "~/helpers/teams/TeamCollection"
getFoldersByPath, import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
resolveSaveContextOnCollectionReorder, import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
updateSaveContextForAffectedRequests,
updateInheritedPropertiesForAffectedRequests,
resetTeamRequestsContext,
} from "~/helpers/collection/collection"
import { currentReorderingStatus$ } from "~/newstore/reordering"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service" import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service"
import { PersistenceService } from "~/services/persistence" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { Picked } from "~/helpers/types/HoppPicked"
import {
addRESTCollection,
addRESTFolder,
cascadeParentCollectionForHeaderAuth,
editRESTCollection,
editRESTFolder,
editRESTRequest,
moveRESTFolder,
moveRESTRequest,
navigateToFolderWithIndexPath,
removeRESTCollection,
removeRESTFolder,
removeRESTRequest,
restCollectionStore,
restCollections$,
saveRESTRequestAs,
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "~/newstore/collections"
import {
selectedEnvironmentIndex$,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { useLocalState } from "~/newstore/localstate"
import { currentReorderingStatus$ } from "~/newstore/reordering"
import { platform } from "~/platform"
import { PersistedOAuthConfig } from "~/services/oauth/oauth.service" import { PersistedOAuthConfig } from "~/services/oauth/oauth.service"
import { PersistenceService } from "~/services/persistence"
import { RESTTabService } from "~/services/tab/rest"
import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service"
import { RESTOptionTabs } from "../http/RequestOptions.vue" import { RESTOptionTabs } from "../http/RequestOptions.vue"
import { Collection as NodeCollection } from "./MyCollections.vue"
import { EditingProperties } from "./Properties.vue" import { EditingProperties } from "./Properties.vue"
const t = useI18n() const t = useI18n()
@@ -339,6 +355,16 @@ const teamLoadingCollections = useReadonlyStream(
teamCollectionAdapter.loadingCollections$, teamCollectionAdapter.loadingCollections$,
[] []
) )
const teamEnvironmentAdapter = new TeamEnvironmentAdapter(undefined)
const teamEnvironmentList = useReadonlyStream(
teamEnvironmentAdapter.teamEnvironmentList$,
[]
)
const selectedEnvironmentIndex = useStream(
selectedEnvironmentIndex$,
{ type: "NO_ENV_SELECTED" },
setSelectedEnvironmentIndex
)
const { const {
cascadeParentCollectionForHeaderAuthForSearchResults, cascadeParentCollectionForHeaderAuthForSearchResults,
@@ -482,6 +508,8 @@ watch(
switchToMyCollections() switchToMyCollections()
} else if (newWorkspace.type === "team") { } else if (newWorkspace.type === "team") {
updateSelectedTeam(newWorkspace) updateSelectedTeam(newWorkspace)
teamEnvironmentAdapter.changeTeamID(newWorkspace.teamID)
} }
}, },
{ {
@@ -630,6 +658,10 @@ const showModalEditProperties = ref(false)
const showConfirmModal = ref(false) const showConfirmModal = ref(false)
const showTeamModalAdd = ref(false) const showTeamModalAdd = ref(false)
const showCollectionsRunnerModal = ref(false)
const selectedCollectionID = ref<string | null>(null)
const activeEnvironmentID = ref<string | null | undefined>(null)
const displayModalAdd = (show: boolean) => { const displayModalAdd = (show: boolean) => {
showModalAdd.value = show showModalAdd.value = show
@@ -2251,6 +2283,27 @@ const setCollectionProperties = (newCollection: {
displayModalEditProperties(false) displayModalEditProperties(false)
} }
const runCollectionHandler = (collectionID: string) => {
selectedCollectionID.value = collectionID
showCollectionsRunnerModal.value = true
const activeWorkspace = workspace.value
const currentEnv = selectedEnvironmentIndex.value
if (["NO_ENV_SELECTED", "MY_ENV"].includes(currentEnv.type)) {
activeEnvironmentID.value = null
return
}
if (activeWorkspace.type === "team" && currentEnv.type === "TEAM_ENV") {
activeEnvironmentID.value = teamEnvironmentList.value.find(
(env) =>
env.teamID === activeWorkspace.teamID &&
env.environment.id === currentEnv.environment.id
)?.environment.id
}
}
const resolveConfirmModal = (title: string | null) => { const resolveConfirmModal = (title: string | null) => {
if (title === `${t("confirm.remove_collection")}`) onRemoveCollection() if (title === `${t("confirm.remove_collection")}`) onRemoveCollection()
else if (title === `${t("confirm.remove_request")}`) onRemoveRequest() else if (title === `${t("confirm.remove_request")}`) onRemoveRequest()

View File

@@ -0,0 +1,123 @@
<template>
<HoppSmartModal
dialog
:full-width-body="true"
:title="t('environment.properties')"
@close="hideModal"
>
<template #body>
<HoppSmartTabs
v-model="activeTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10 !-py-4"
render-inactive-tabs
>
<HoppSmartTab id="details" :label="t('environment.details')">
<div
class="flex flex-shrink-0 items-center justify-between border-b border-dividerLight bg-primary pl-4"
>
<span>{{ t("collection_runner.environment_id") }}</span>
<!-- TODO: Make it point to the section about accessing environments via the ID -->
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/clients/cli"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</div>
<div class="p-4">
<div
class="flex items-center justify-between py-2 px-4 rounded-md bg-primaryLight select-text"
>
<div class="text-secondaryDark">
{{ environmentID }}
</div>
<HoppButtonSecondary
filled
:icon="copyTextIcon"
@click="copyText"
/>
</div>
</div>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
>
<icon-lucide-info class="svg-icons mr-2" />
{{ t("collection_runner.cli_environment_id_description") }}
</div>
</HoppSmartTab>
</HoppSmartTabs>
</template>
<template #footer>
<div class="flex gap-x-2 items-center">
<HoppButtonPrimary
:label="t('action.copy')"
:icon="copyIcon"
outline
filled
@click="copyEnvironmentID"
/>
<HoppButtonSecondary
:label="t('action.close')"
outline
filled
@click="hideModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { refAutoReset, useVModel } from "@vueuse/core"
import { toRef } from "vue"
import { useCopyResponse } from "~/composables/lens-actions"
import { useToast } from "~/composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconHelpCircle from "~icons/lucide/help-circle"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelValue: string
environmentID: string
}>()
const environmentIDRef = toRef(props, "environmentID")
const { copyIcon: copyTextIcon, copyResponse: copyText } =
useCopyResponse(environmentIDRef)
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update:modelValue"): void
}>()
const activeTab = useVModel(props, "modelValue", emit)
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const hideModal = () => {
emit("hide-modal")
}
const copyEnvironmentID = () => {
copyToClipboard(props.environmentID)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
</script>

View File

@@ -40,7 +40,8 @@
@keyup.d="duplicate!.$el.click()" @keyup.d="duplicate!.$el.click()"
@keyup.j="exportAsJsonEl!.$el.click()" @keyup.j="exportAsJsonEl!.$el.click()"
@keyup.delete="deleteAction!.$el.click()" @keyup.delete="deleteAction!.$el.click()"
@keyup.escape="options!.tippy().hide()" @keyup.p="propertiesAction!.$el.click()"
@keyup.escape="options!.tippy?.hide()"
> >
<HoppSmartItem <HoppSmartItem
ref="edit" ref="edit"
@@ -94,6 +95,18 @@
} }
" "
/> />
<HoppSmartItem
ref="propertiesAction"
:icon="IconSettings2"
:label="t('action.properties')"
:shortcut="['P']"
@click="
() => {
emit('show-environment-properties')
hide()
}
"
/>
</div> </div>
</template> </template>
</tippy> </tippy>
@@ -108,26 +121,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { HoppSmartItem } from "@hoppscotch/ui"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { GQLError } from "~/helpers/backend/GQLClient"
import { import {
deleteTeamEnvironment, deleteTeamEnvironment,
createDuplicateEnvironment as duplicateEnvironment, createDuplicateEnvironment as duplicateEnvironment,
} from "~/helpers/backend/mutations/TeamEnvironment" } from "~/helpers/backend/mutations/TeamEnvironment"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment" import { exportAsJSON } from "~/helpers/import-export/export/environment"
import { useService } from "dioc/vue" import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { SecretEnvironmentService } from "~/services/secret-environment.service" import { SecretEnvironmentService } from "~/services/secret-environment.service"
import IconCopy from "~icons/lucide/copy"
import IconEdit from "~icons/lucide/edit"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@@ -139,6 +154,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: "edit-environment"): void (e: "edit-environment"): void
(e: "show-environment-properties"): void
}>() }>()
const secretEnvironmentService = useService(SecretEnvironmentService) const secretEnvironmentService = useService(SecretEnvironmentService)
@@ -156,6 +172,7 @@ const edit = ref<typeof HoppSmartItem>()
const duplicate = ref<typeof HoppSmartItem>() const duplicate = ref<typeof HoppSmartItem>()
const deleteAction = ref<typeof HoppSmartItem>() const deleteAction = ref<typeof HoppSmartItem>()
const exportAsJsonEl = ref<typeof HoppSmartItem>() const exportAsJsonEl = ref<typeof HoppSmartItem>()
const propertiesAction = ref<typeof HoppSmartItem>()
const removeEnvironment = () => { const removeEnvironment = () => {
pipe( pipe(

View File

@@ -86,6 +86,9 @@
:environment="environment" :environment="environment"
:is-viewer="team?.role === 'VIEWER'" :is-viewer="team?.role === 'VIEWER'"
@edit-environment="editEnvironment(environment)" @edit-environment="editEnvironment(environment)"
@show-environment-properties="
showEnvironmentProperties(environment.environment.id)
"
/> />
</div> </div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4"> <div v-if="loading" class="flex flex-col items-center justify-center p-4">
@@ -116,6 +119,12 @@
environment-type="TEAM_ENV" environment-type="TEAM_ENV"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />
<EnvironmentsProperties
v-if="showEnvironmentsPropertiesModal"
v-model="environmentsPropertiesModalActiveTab"
:environment-i-d="selectedEnvironmentID!"
@hide-modal="showEnvironmentsPropertiesModal = false"
/>
</div> </div>
</template> </template>
@@ -149,6 +158,10 @@ const editingEnvironment = ref<TeamEnvironment | null>(null)
const editingVariableName = ref("") const editingVariableName = ref("")
const secretOptionSelected = ref(false) const secretOptionSelected = ref(false)
const showEnvironmentsPropertiesModal = ref(false)
const environmentsPropertiesModalActiveTab = ref("details")
const selectedEnvironmentID = ref<string | null>(null)
const isTeamViewer = computed(() => props.team?.role === "VIEWER") const isTeamViewer = computed(() => props.team?.role === "VIEWER")
const displayModalAdd = (shouldDisplay: boolean) => { const displayModalAdd = (shouldDisplay: boolean) => {
@@ -187,6 +200,11 @@ const getErrorMessage = (err: GQLError<string>) => {
} }
} }
const showEnvironmentProperties = (environmentID: string) => {
showEnvironmentsPropertiesModal.value = true
selectedEnvironmentID.value = environmentID
}
defineActionHandler( defineActionHandler(
"modals.team.environment.edit", "modals.team.environment.edit",
({ envName, variableName, isSecret }) => { ({ envName, variableName, isSecret }) => {

View File

@@ -20,6 +20,7 @@ interface ImportMetaEnv {
readonly VITE_BACKEND_GQL_URL: string readonly VITE_BACKEND_GQL_URL: string
readonly VITE_BACKEND_WS_URL: string readonly VITE_BACKEND_WS_URL: string
readonly VITE_BACKEND_API_URL: string
readonly VITE_SENTRY_DSN?: string readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string readonly VITE_SENTRY_ENVIRONMENT?: string