feat : smart tree component (#2865)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Nivedin
2023-01-31 17:15:03 +05:30
committed by GitHub
parent b95e2b365a
commit 2910164d5a
39 changed files with 4483 additions and 4142 deletions

View File

@@ -391,6 +391,7 @@
"copy_link": "Copy link",
"duration": "Duration",
"enter_curl": "Enter cURL command",
"duplicated": "Request duplicated",
"generate_code": "Generate code",
"generated_code": "Generated code",
"header_list": "Header List",

View File

@@ -32,7 +32,7 @@ declare module '@vue/runtime-core' {
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
CollectionsChooseType: typeof import('./components/collections/ChooseType.vue')['default']
CollectionsCollection: typeof import('./components/collections/Collection.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@@ -48,13 +48,11 @@ declare module '@vue/runtime-core' {
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
CollectionsMyCollection: typeof import('./components/collections/my/Collection.vue')['default']
CollectionsMyFolder: typeof import('./components/collections/my/Folder.vue')['default']
CollectionsMyRequest: typeof import('./components/collections/my/Request.vue')['default']
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamsCollection: typeof import('./components/collections/teams/Collection.vue')['default']
CollectionsTeamsFolder: typeof import('./components/collections/teams/Folder.vue')['default']
CollectionsTeamsRequest: typeof import('./components/collections/teams/Request.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CollectionsTeamSelect: typeof import('./components/collections/TeamSelect.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -152,6 +150,8 @@ declare module '@vue/runtime-core' {
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartTree: typeof import('./components/smart/Tree.vue')['default']
SmartTreeBranch: typeof import('./components/smart/TreeBranch.vue')['default']
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default']

View File

@@ -41,47 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { watch, ref } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
export default defineComponent({
props: {
show: Boolean,
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (!show) {
name.value = ""
}
},
data() {
return {
name: null,
}
},
watch: {
show(isShowing: boolean) {
if (!isShowing) {
this.name = null
}
},
},
methods: {
addNewCollection() {
if (!this.name) {
this.toast.error(this.t("collection.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
}
)
const addNewCollection = () => {
if (!name.value) {
toast.error(t("collection.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('folder.new')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
@@ -41,52 +41,51 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
folder: { type: Object, default: () => ({}) },
folderPath: { type: String, default: null },
collectionIndex: { type: Number, default: null },
loadingState: Boolean,
},
emits: ["hide-modal", "add-folder"],
setup() {
return {
toast: useToast(),
t: useI18n(),
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "add-folder", name: string): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (!show) {
name.value = ""
}
},
data() {
return {
name: null,
}
},
watch: {
show(isShowing: boolean) {
if (!isShowing) this.name = null
},
},
methods: {
addFolder() {
if (!this.name) {
this.toast.error(this.t("folder.invalid_name"))
return
}
this.$emit("add-folder", {
name: this.name,
folder: this.folder,
path: this.folderPath || `${this.collectionIndex}`,
})
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
}
)
const addFolder = () => {
if (name.value.trim() === "") {
toast.error(t("folder.invalid_name"))
return
}
emit("add-folder", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -48,23 +48,20 @@ import { getRESTRequest } from "~/newstore/RESTSession"
const toast = useToast()
const t = useI18n()
const props = defineProps<{
show: boolean
loadingState: boolean
folder?: object
folderPath?: string
}>()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-request",
v: {
name: string
folder: object | undefined
path: string | undefined
}
): void
(event: "hide-modal"): void
(event: "add-request", name: string): void
}>()
const name = ref("")
@@ -79,15 +76,11 @@ watch(
)
const addRequest = () => {
if (!name.value) {
if (name.value.trim() === "") {
toast.error(`${t("error.empty_req_name")}`)
return
}
emit("add-request", {
name: name.value,
folder: props.folder,
path: props.folderPath,
})
emit("add-request", name.value)
}
const hideModal = () => {

View File

@@ -1,162 +0,0 @@
<template>
<div>
<SmartTabs
:id="'collections_tab'"
v-model="selectedCollectionTab"
render-inactive-tabs
>
<SmartTab
:id="'my-collections'"
:label="`${t('collection.my_collections')}`"
/>
<SmartTab
:id="'team-collections'"
:label="`${t('collection.team_collections')}`"
>
<SmartIntersection @intersecting="onTeamSelectIntersect">
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions.focus()"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('collection.select_team')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
>
<ButtonSecondary
v-if="collectionsType.selectedTeam"
:icon="IconUsers"
:label="collectionsType.selectedTeam.name"
class="flex-1 !justify-start pr-8 rounded-none"
/>
<ButtonSecondary
v-else
:label="`${t('collection.select_team')}`"
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="(team, index) in myTeams"
:key="`team-${index}`"
:label="team.name"
:info-icon="
team.id === collectionsType.selectedTeam?.id
? IconDone
: undefined
"
:active-info-icon="
team.id === collectionsType.selectedTeam?.id
"
:icon="IconUsers"
@click="
() => {
updateSelectedTeam(team)
hide()
}
"
/>
</div>
</template>
</tippy>
</SmartIntersection>
</SmartTab>
</SmartTabs>
</div>
</template>
<script setup lang="ts">
import IconUsers from "~icons/lucide/users"
import IconDone from "~icons/lucide/check"
import { nextTick, ref, watch } from "vue"
import { GetMyTeamsQuery, Team } from "~/helpers/backend/graphql"
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useReadonlyStream } from "@composables/stream"
import { onLoggedIn } from "@composables/auth"
import { useI18n } from "@composables/i18n"
import { useLocalState } from "~/newstore/localstate"
import { invokeAction } from "~/helpers/actions"
type TeamData = GetMyTeamsQuery["myTeams"][number]
type CollectionTabs = "my-collections" | "team-collections"
const t = useI18n()
// Template refs
const tippyActions = ref<any | null>(null)
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
defineProps<{
collectionsType: {
type: "my-collections" | "team-collections"
selectedTeam: Team | undefined
}
}>()
const emit = defineEmits<{
(e: "update-collection-type", tabID: string): void
(e: "update-selected-team", team: TeamData | undefined): void
}>()
const currentUser = useReadonlyStream(currentUserInfo$, null)
const adapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(adapter.teamList$, null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
let teamListFetched = false
watch(myTeams, (teams) => {
if (teams && !teamListFetched) {
teamListFetched = true
if (REMEMBERED_TEAM_ID.value && currentUser) {
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) updateSelectedTeam(team)
}
}
})
onLoggedIn(() => {
adapter.initialize()
})
watch(
() => currentUser.value,
(user) => {
if (!user) {
selectedCollectionTab.value = "my-collections"
}
}
)
const onTeamSelectIntersect = () => {
// Load team data as soon as intersection
adapter.fetchList()
}
const updateCollectionsType = (tabID: string) => {
emit("update-collection-type", tabID)
}
const updateSelectedTeam = (team: TeamData | undefined) => {
REMEMBERED_TEAM_ID.value = team?.id
emit("update-selected-team", team)
}
watch(selectedCollectionTab, (newValue: CollectionTabs) => {
if (newValue === "team-collections" && !currentUser.value) {
invokeAction("modals.login.toggle")
nextTick(() => (selectedCollectionTab.value = "my-collections"))
} else updateCollectionsType(newValue)
})
</script>

View File

@@ -0,0 +1,252 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="emit('toggle-children')"
>
<component
:is="collectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="emit('toggle-children')"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }}
</span>
</span>
<div v-if="!hasNoTeamAccess" class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-request')"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-folder')"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction?.$el.click()"
@keyup.n="folderAction?.$el.click()"
@keyup.e="edit?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
emit('add-request')
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
emit('add-folder')
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="
() => {
emit('export-data'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-collection')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</template>
<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 { PropType, ref, computed, watch } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder"
const t = useI18n()
const props = defineProps({
data: {
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
default: () => ({}),
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
/**
* Collection component can be used for both collections and folders.
* folderType is used to determine which one it is.
*/
folderType: {
type: String as PropType<FolderType>,
default: "collection",
required: true,
},
isOpen: {
type: Boolean,
default: false,
required: true,
},
isSelected: {
type: Boolean,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(event: "toggle-children"): void
(event: "add-request"): void
(event: "add-folder"): void
(event: "edit-collection"): void
(event: "export-data"): void
(event: "remove-collection"): void
(event: "drop-event", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const dragging = ref(false)
const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle
else if (!props.isOpen) return IconFolder
else if (props.isOpen) return IconFolderOpen
else return IconFolder
})
const collectionName = computed(() => {
if ((props.data as HoppCollection<HoppRESTRequest>).name)
return (props.data as HoppCollection<HoppRESTRequest>).name
else return (props.data as TeamCollection).title
})
watch(
() => props.exportLoading,
(val) => {
if (!val) {
options.value!.tippy.hide()
}
}
)
const dropEvent = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
emit("drop-event", dataTransfer)
}
}
</script>

View File

@@ -41,46 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
export default defineComponent({
props: {
show: Boolean,
editingCollectionName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: null,
}
},
watch: {
editingCollectionName(val) {
this.name = val
},
},
methods: {
saveCollection() {
if (!this.name) {
this.toast.error(this.t("collection.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingCollectionName: string
}>(),
{
show: false,
loadingState: false,
editingCollectionName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingCollectionName,
(newName) => {
name.value = newName
}
)
const saveCollection = () => {
if (name.value.trim() === "") {
toast.error(t("collection.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('folder.edit')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
@@ -41,46 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
editingFolderName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
t: useI18n(),
toast: useToast(),
}
},
data() {
return {
name: null,
}
},
watch: {
editingFolderName(val) {
this.name = val
},
},
methods: {
editFolder() {
if (!this.name) {
this.toast.error(this.t("folder.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingFolderName: string
}>(),
{
show: false,
loadingState: false,
editingFolderName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingFolderName,
(newName) => {
name.value = newName
}
)
const editFolder = () => {
if (name.value.trim() === "") {
toast.error(t("folder.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -9,13 +9,13 @@
<div class="flex flex-col">
<input
id="selectLabelEditReq"
v-model="requestUpdateData.name"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveRequest"
@keyup.enter="editRequest"
/>
<label for="selectLabelEditReq">
{{ t("action.label") }}
@@ -28,7 +28,7 @@
:label="t('action.save')"
:loading="loadingState"
outline
@click="saveRequest"
@click="editRequest"
/>
<ButtonSecondary
:label="t('action.cancel')"
@@ -41,48 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
editingRequestName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
t: useI18n(),
toast: useToast(),
}
},
data() {
return {
requestUpdateData: {
name: null,
},
}
},
watch: {
editingRequestName(val) {
this.requestUpdateData.name = val
},
},
methods: {
saveRequest() {
if (!this.requestUpdateData.name) {
this.toast.error(this.t("request.invalid_name"))
return
}
this.$emit("submit", this.requestUpdateData)
},
hideModal() {
this.requestUpdateData = { name: null }
this.$emit("hide-modal")
},
},
})
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingRequestName: string
}>(),
{
show: false,
loadingState: false,
editingRequestName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingRequestName,
(newName) => {
name.value = newName
}
)
const editRequest = () => {
if (name.value.trim() === "") {
toast.error(t("request.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -2,7 +2,7 @@
<SmartModal
v-if="show"
dialog
:title="`${t('modal.collections')}`"
:title="t('modal.collections')"
styles="sm:max-w-md"
@close="hideModal"
>
@@ -81,7 +81,6 @@
<div class="select-wrapper">
<select
v-model="mySelectedCollectionID"
type="text"
autocomplete="off"
class="select"
autofocus
@@ -93,6 +92,7 @@
v-for="(collection, collectionIndex) in myCollections"
:key="`collection-${collectionIndex}`"
:value="collectionIndex"
class="bg-primary"
>
{{ collection.name }}
</option>
@@ -126,8 +126,9 @@
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:loading="exportingTeamCollections"
:label="t('export.as_json')"
@click="exportJSON"
@click="emit('export-json-collection')"
/>
<span
v-tippy="{ theme: 'tooltip' }"
@@ -149,12 +150,9 @@
: false
"
:icon="IconGithub"
:loading="creatingGistCollection"
:label="t('export.create_secret_gist')"
@click="
() => {
createCollectionGist()
}
"
@click="emit('create-collection-gist')"
/>
</span>
</div>
@@ -167,11 +165,10 @@
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref, watch } from "vue"
import { computed, PropType, ref, watch } from "vue"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import axios from "axios"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
@@ -179,205 +176,85 @@ import { currentUser$ } from "~/helpers/fb/auth"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
import { StepReturnValue } from "~/helpers/import-export/steps"
import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient"
import {
ExportAsJsonDocument,
ImportFromJsonDocument,
} from "~/helpers/backend/graphql"
const props = defineProps<{
show: boolean
collectionsType:
| {
type: "team-collections"
selectedTeam: {
id: string
}
}
| { type: "my-collections" }
}>()
const toast = useToast()
const t = useI18n()
type CollectionType = "team-collections" | "my-collections"
const props = defineProps({
show: {
type: Boolean,
default: false,
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
exportingTeamCollections: {
type: Boolean,
default: false,
required: false,
},
creatingGistCollection: {
type: Boolean,
default: false,
required: false,
},
importingMyCollections: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update-team-collections"): void
(e: "export-json-collection"): void
(e: "create-collection-gist"): void
(e: "import-to-teams", payload: HoppCollection<HoppRESTRequest>[]): void
}>()
const toast = useToast()
const t = useI18n()
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const hasFile = ref(false)
const hasGist = ref(false)
// Template refs
const mode = ref("import_export")
const mySelectedCollectionID = ref<undefined | number>(undefined)
const collectionJson = ref("")
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const inputChooseGistToImportFrom = ref<string>("")
const getJSONCollection = async () => {
if (props.collectionsType.type === "my-collections") {
collectionJson.value = JSON.stringify(myCollections.value, null, 2)
} else {
collectionJson.value = pipe(
await runGQLQuery({
query: ExportAsJsonDocument,
variables: {
teamID: props.collectionsType.selectedTeam.id,
},
}),
E.matchW(
// TODO: Handle error case gracefully ?
() => {
throw new Error("Error exporting collection to JSON")
},
(x) => x.exportCollectionsToJSON
)
)
}
return collectionJson.value
}
const createCollectionGist = async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission").toString())
return
}
await getJSONCollection()
try {
const res = await axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-collections.json": {
content: collectionJson.value,
},
},
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
},
}
)
toast.success(t("export.gist_created").toString())
window.open(res.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
}
}
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
mode.value = "import_export"
mySelectedCollectionID.value = undefined
resetImport()
emit("hide-modal")
}
const importerType = ref<number | null>(null)
const stepResults = ref<StepReturnValue[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const mySelectedCollectionID = ref<number | undefined>(undefined)
const inputChooseGistToImportFrom = ref<string>("")
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType) ?? true
)
)
const importerModule = computed(() => {
if (importerType.value === null) return null
return importerModules.value[importerType.value]
})
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
watch(mySelectedCollectionID, (newValue) => {
if (newValue === undefined) return
stepResults.value = []
stepResults.value.push(newValue)
})
const importingMyCollections = ref(false)
const importToTeams = async (content: HoppCollection<HoppRESTRequest>) => {
importingMyCollections.value = true
if (props.collectionsType.type !== "team-collections") return
const result = await runMutation(ImportFromJsonDocument, {
jsonString: JSON.stringify(content),
teamID: props.collectionsType.selectedTeam.id,
})()
if (E.isLeft(result)) {
console.error(result.left)
} else {
emit("update-team-collections")
}
importingMyCollections.value = false
}
const exportJSON = async () => {
await getJSONCollection()
const dataToWrite = collectionJson.value
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
}
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType.type) ?? true
)
)
const importerType = ref<number | null>(null)
const importerModule = computed(() =>
importerType.value !== null ? importerModules.value[importerType.value] : null
)
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const finishImport = async () => {
await importerAction(stepResults.value)
}
const importerAction = async (stepResults: any[]) => {
if (!importerModule.value) return
const result = await importerModule.value?.importer(stepResults as any)()
if (E.isLeft(result)) {
failedImport()
console.error("error", result.left)
} else if (E.isRight(result)) {
if (props.collectionsType.type === "team-collections") {
importToTeams(result.right)
fileImported()
} else {
appendRESTCollections(result.right)
fileImported()
}
}
}
const hasFile = ref(false)
const hasGist = ref(false)
watch(inputChooseGistToImportFrom, (v) => {
watch(inputChooseGistToImportFrom, (url) => {
stepResults.value = []
if (v === "") {
if (url === "") {
hasGist.value = false
} else {
hasGist.value = true
@@ -385,17 +262,46 @@ watch(inputChooseGistToImportFrom, (v) => {
}
})
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const importerAction = async (stepResults: StepReturnValue[]) => {
if (!importerModule.value) return
pipe(
await importerModule.value.importer(stepResults as any)(),
E.match(
(err) => {
failedImport()
console.error("error", err)
},
(result) => {
if (props.collectionsType === "team-collections") {
emit("import-to-teams", result)
} else {
appendRESTCollections(result)
fileImported()
}
}
)
)
}
const finishImport = async () => {
await importerAction(stepResults.value)
}
const onFileChange = () => {
stepResults.value = []
if (!inputChooseFileToImportFrom.value[0]) {
const inputFileToImport = inputChooseFileToImportFrom.value[0]
if (!inputFileToImport) {
hasFile.value = false
return
}
if (
!inputChooseFileToImportFrom.value[0].files ||
inputChooseFileToImportFrom.value[0].files.length === 0
) {
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
@@ -403,6 +309,7 @@ const onFileChange = () => {
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
@@ -414,20 +321,29 @@ const onFileChange = () => {
stepResults.value.push(content)
hasFile.value = !!content?.length
}
reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
reader.readAsText(inputFileToImport.files[0])
}
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
resetImport()
emit("hide-modal")
}
const resetImport = () => {
importerType.value = null
hasFile.value = false
hasGist.value = false
stepResults.value = []
inputChooseFileToImportFrom.value = ""
hasFile.value = false
inputChooseGistToImportFrom.value = ""
hasGist.value = false
mySelectedCollectionID.value = undefined
}
</script>

View File

@@ -0,0 +1,619 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
saveRequest
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-primary-sticky-fold)'
"
>
<ButtonSecondary
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconArchive"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
</span>
</div>
<div class="flex flex-col flex-1">
<SmartTree :adapter="myAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<CollectionsCollection
v-if="node.data.type === 'collections'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:is-selected="
isSelected({
collectionIndex: parseInt(node.id),
})
"
folder-type="collection"
@add-request="
node.data.type === 'collections' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@drop-event="dropEvent($event, node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'my-collection',
collectionIndex: parseInt(node.id),
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:is-selected="
isSelected({
folderPath: node.id,
})
"
folder-type="folder"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'folders' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'folders' &&
emit('edit-folder', {
folderPath: node.id,
folder: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-folder', node.id)"
@drop-event="dropEvent($event, node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'my-folder',
folderPath: node.id,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data"
:collections-type="collectionsType.type"
:save-request="saveRequest"
:is-active="
isActiveRequest(
node.data.data.parentIndex,
parseInt(pathToIndex(node.id))
)
"
:is-selected="
isSelected({
folderPath: node.data.data.parentIndex,
requestIndex: parseInt(pathToIndex(node.id)),
})
"
@edit-request="
node.data.type === 'requests' &&
emit('edit-request', {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
request: node.data.data.data,
})
"
@duplicate-request="
node.data.type === 'requests' &&
emit('duplicate-request', {
folderPath: node.data.data.parentIndex,
request: node.data.data.data,
})
"
@remove-request="
node.data.type === 'requests' &&
emit('remove-request', {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
@select-request="
node.data.type === 'requests' &&
selectRequest({
request: node.data.data.data,
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
/>
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
class="mb-4"
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
class="mb-4"
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
<div
v-if="
filterText.length !== 0 &&
filteredCollections.length === 0 &&
node === null
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
</template>
</SmartTree>
</div>
</div>
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useReadonlyStream } from "~/composables/stream"
import { restSaveContext$ } from "~/newstore/RESTSession"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
export type Collection = {
type: "collections"
data: {
parentIndex: null
data: HoppCollection<HoppRESTRequest>
}
}
type Folder = {
type: "folders"
data: {
parentIndex: string
data: HoppCollection<HoppRESTRequest>
}
}
type Requests = {
type: "requests"
data: {
parentIndex: string
data: HoppRESTRequest
}
}
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
const props = defineProps({
filteredCollections: {
type: Array as PropType<HoppCollection<HoppRESTRequest>[]>,
default: () => [],
required: true,
},
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
filterText: {
type: String as PropType<string>,
default: "",
required: true,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
picked: {
type: Object as PropType<Picked | null>,
default: null,
required: false,
},
})
const emit = defineEmits<{
(event: "display-modal-add"): void
(
event: "add-request",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "add-folder",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-folder",
payload: {
folderPath: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-request",
payload: {
folderPath: string
requestIndex: string
request: HoppRESTRequest
}
): void
(
event: "duplicate-request",
payload: {
folderPath: string
request: HoppRESTRequest
}
): void
(event: "export-data", payload: HoppCollection<HoppRESTRequest>): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
event: "remove-request",
payload: {
folderPath: string | null
requestIndex: string
}
): void
(
event: "select-request",
payload: {
request: HoppRESTRequest
folderPath: string
requestIndex: string
isActive: boolean
}
): void
(
event: "drop-request",
payload: {
folderPath: string
requestIndex: string
collectionIndex: string
}
): void
(event: "select", payload: Picked | null): void
(event: "display-modal-import-export"): void
}>()
const refFilterCollection = toRef(props, "filteredCollections")
const pathToIndex = computed(() => {
return (path: string) => {
const pathArr = path.split("/")
return pathArr[pathArr.length - 1]
}
})
const isSelected = computed(() => {
return ({
collectionIndex,
folderPath,
requestIndex,
}: {
collectionIndex?: number | undefined
folderPath?: string | undefined
requestIndex?: number | undefined
}) => {
if (collectionIndex !== undefined) {
return (
props.picked &&
props.picked.pickedType === "my-collection" &&
props.picked.collectionIndex === collectionIndex
)
} else if (requestIndex !== undefined && folderPath !== undefined) {
return (
props.picked &&
props.picked.pickedType === "my-request" &&
props.picked.folderPath === folderPath &&
props.picked.requestIndex === requestIndex
)
} else {
return (
props.picked &&
props.picked.pickedType === "my-folder" &&
props.picked.folderPath === folderPath
)
}
}
})
const active = useReadonlyStream(restSaveContext$, null)
const isActiveRequest = computed(() => {
return (folderPath: string, requestIndex: number) => {
return pipe(
active.value,
O.fromNullable,
O.filter(
(active) =>
active.originLocation === "user-collection" &&
active.folderPath === folderPath &&
active.requestIndex === requestIndex
),
O.isSome
)
}
})
const selectRequest = (data: {
request: HoppRESTRequest
folderPath: string
requestIndex: string
}) => {
const { request, folderPath, requestIndex } = data
if (props.saveRequest) {
emit("select", {
pickedType: "my-request",
folderPath: folderPath,
requestIndex: parseInt(requestIndex),
})
} else {
emit("select-request", {
request,
folderPath,
requestIndex,
isActive: isActiveRequest.value(folderPath, parseInt(requestIndex)),
})
}
}
const dragRequest = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
dataTransfer.setData("folderPath", folderPath)
dataTransfer.setData("requestIndex", requestIndex)
}
const dropEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
emit("drop-request", {
folderPath,
requestIndex,
collectionIndex,
})
}
type MyCollectionNode = Collection | Folder | Requests
class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
constructor(public data: Ref<HoppCollection<HoppRESTRequest>[]>) {}
navigateToFolderWithIndexPath(
collections: HoppCollection<HoppRESTRequest>[],
indexPaths: number[]
) {
if (indexPaths.length === 0) return null
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null
}
getChildren(id: string | null): Ref<ChildrenResult<MyCollectionNode>> {
return computed(() => {
if (id === null) {
const data = this.data.value.map((item, index) => ({
id: index.toString(),
data: {
type: "collections",
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: data,
} as ChildrenResult<Collection>
}
const indexPath = id.split("/").map((x) => parseInt(x))
const item = this.navigateToFolderWithIndexPath(
this.data.value,
indexPath
)
if (item) {
const data = [
...item.folders.map((item, index) => ({
id: `${id}/${index}`,
data: {
type: "folders",
data: {
parentIndex: id,
data: item,
},
},
})),
...item.requests.map((item, index) => ({
id: `${id}/${index}`,
data: {
type: "requests",
data: {
parentIndex: id,
data: item,
},
},
})),
]
return {
status: "loaded",
data: data,
} as ChildrenResult<Folder | Requests>
} else {
return {
status: "loaded",
data: [],
}
}
})
}
}
const myAdapter: SmartTreeAdapter<MyCollectionNode> = new MyCollectionsAdapter(
refFilterCollection
)
</script>

View File

@@ -0,0 +1,235 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="requestLabelColor"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div v-if="!hasNoTeamAccess" class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request')
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:loading="duplicateLoading"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-request')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
type CollectionType = "my-collections" | "team-collections"
const t = useI18n()
const props = defineProps({
request: {
type: Object as PropType<HoppRESTRequest>,
default: () => ({}),
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
duplicateLoading: {
type: Boolean,
default: false,
required: false,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
isActive: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
isSelected: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(event: "edit-request"): void
(event: "duplicate-request"): void
(event: "remove-request"): void
(event: "select-request"): void
(event: "drag-request", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(props.request.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
watch(
() => props.duplicateLoading,
(val) => {
if (!val) {
options.value!.tippy.hide()
}
}
)
const selectRequest = () => {
emit("select-request")
}
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
emit("drag-request", dataTransfer)
}
}
</script>

View File

@@ -37,8 +37,8 @@
:picked="picked"
:save-request="true"
@select="onSelect"
@update-collection="updateColl"
@update-coll-type="onUpdateCollType"
@update-team="updateTeam"
@update-collection-type="updateCollectionType"
/>
</div>
</template>
@@ -46,6 +46,7 @@
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
:loading="modalLoadingState"
outline
@click="saveRequestAs"
/>
@@ -61,99 +62,75 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
HoppGQLRequest,
HoppRESTRequest,
isHoppRESTRequest,
} from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { cloneDeep } from "lodash-es"
import { reactive, ref, watch } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import {
createRequestInCollection,
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { Picked } from "~/helpers/types/HoppPicked"
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
import {
getRESTRequest,
setRESTSaveContext,
useRESTRequestName,
} from "~/newstore/RESTSession"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { runMutation } from "~/helpers/backend/GQLClient"
import {
CreateRequestInCollectionDocument,
UpdateRequestDocument,
} from "~/helpers/backend/graphql"
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
import { GQLError } from "~/helpers/backend/GQLClient"
const t = useI18n()
const toast = useToast()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "my-collections"
}
| {
type: "team-collections"
// TODO: Figure this type out
selectedTeam: {
id: string
}
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
type Picked =
| {
pickedType: "my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "my-folder"
folderPath: string
}
| {
pickedType: "my-collection"
collectionIndex: number
}
| {
pickedType: "teams-request"
requestID: string
}
| {
pickedType: "teams-folder"
folderID: string
}
| {
pickedType: "teams-collection"
collectionID: string
}
| {
pickedType: "gql-my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "gql-my-folder"
folderPath: string
}
| {
pickedType: "gql-my-collection"
collectionIndex: number
}
const props = defineProps<{
mode: "rest" | "graphql"
show: boolean
}>()
const props = withDefaults(
defineProps<{
show: boolean
mode: "rest" | "graphql"
}>(),
{
show: false,
mode: "rest",
}
)
const emit = defineEmits<{
(
event: "edit-request",
payload: {
folderPath: string
requestIndex: string
request: HoppRESTRequest
}
): void
(e: "hide-modal"): void
}>()
const toast = useToast()
// TODO: Use a better implementation with computed ?
// This implementation can't work across updates to mode prop (which won't happen tho)
const requestName =
const requestName = ref(
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
)
const requestData = reactive({
name: requestName,
@@ -164,11 +141,13 @@ const requestData = reactive({
const collectionsType = ref<CollectionType>({
type: "my-collections",
selectedTeam: undefined,
})
// TODO: Figure this type out
const picked = ref<Picked | null>(null)
const modalLoadingState = ref(false)
// Resets
watch(
() => requestData.collectionIndex,
@@ -184,20 +163,18 @@ watch(
}
)
// All the methods
const onUpdateCollType = (newCollType: CollectionType) => {
collectionsType.value = newCollType
const updateTeam = (newTeam: SelectedTeam) => {
collectionsType.value.selectedTeam = newTeam
}
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
const updateCollectionType = (type: CollectionType["type"]) => {
collectionsType.value.type = type
}
const onSelect = (pickedVal: Picked | null) => {
picked.value = pickedVal
}
const hideModal = () => {
picked.value = null
emit("hide-modal")
}
const saveRequestAs = async () => {
if (!requestName.value) {
toast.error(`${t("error.empty_req_name")}`)
@@ -208,35 +185,25 @@ const saveRequestAs = async () => {
return
}
// Clone Deep because objects are shared by reference so updating
// just one bit will update other referenced shared instances
const requestUpdated =
props.mode === "rest"
? cloneDeep(getRESTRequest())
: cloneDeep(getGQLSession().request)
// // Filter out all REST file inputs
// if (this.mode === "rest" && requestUpdated.bodyParams) {
// requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
// param?.value?.[0] instanceof File ? { ...param, value: "" } : param
// )
// }
if (picked.value.pickedType === "my-request") {
if (picked.value.pickedType === "my-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated
)
setRESTSaveContext({
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
req: cloneDeep(requestUpdated),
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
req: requestUpdated,
})
requestSaved()
@@ -253,114 +220,68 @@ const saveRequestAs = async () => {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
req: requestUpdated,
})
requestSaved()
} else if (picked.value.pickedType === "my-collection") {
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated
)
setRESTSaveContext({
originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
req: requestUpdated,
})
requestSaved()
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
throw new Error("Collections Type mismatch")
runMutation(UpdateRequestDocument, {
requestID: picked.value.requestID,
data: {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
throw new Error(`${result.left}`)
} else {
requestSaved()
}
})
setRESTSaveContext({
originLocation: "team-collection",
requestID: picked.value.requestID,
req: cloneDeep(requestUpdated),
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
throw new Error("Collections Type mismatch")
const result = await runMutation(CreateRequestInCollectionDocument, {
collectionID: picked.value.folderID,
data: {
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
title: requestUpdated.name,
},
})()
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
console.error(result.left)
} else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.folderID,
req: cloneDeep(requestUpdated),
})
requestSaved()
}
} else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
const result = await runMutation(CreateRequestInCollectionDocument, {
collectionID: picked.value.collectionID,
data: {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
},
})()
modalLoadingState.value = true
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
console.error(result.left)
} else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.collectionID,
req: cloneDeep(requestUpdated),
})
requestSaved()
const data = {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
}
pipe(
updateTeamRequest(picked.value.requestID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
() => {
modalLoadingState.value = false
requestSaved()
}
)
)()
} else if (picked.value.pickedType === "gql-my-request") {
// TODO: Check for GQL request ?
editGraphqlRequest(
@@ -389,12 +310,81 @@ const saveRequestAs = async () => {
}
}
/**
* Updates a team collection or folder and sets the save context to the updated request
* @param collectionID - ID of the collection or folder
* @param requestUpdated - Updated request
*/
const updateTeamCollectionOrFolder = (
collectionID: string,
requestUpdated: HoppRESTRequest
) => {
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
modalLoadingState.value = true
const data = {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
}
pipe(
createRequestInCollection(collectionID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
(result) => {
const { createRequestInCollection } = result
setRESTSaveContext({
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
teamID: createRequestInCollection.collection.team.id,
req: requestUpdated,
})
modalLoadingState.value = false
requestSaved()
}
)
)()
}
const requestSaved = () => {
toast.success(`${t("request.added")}`)
hideModal()
}
const updateColl = (ev: CollectionType["type"]) => {
collectionsType.value.type = ev
const hideModal = () => {
picked.value = null
emit("hide-modal")
}
const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -0,0 +1,625 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
saveRequest
? 'top: calc(var(--upper-secondary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-secondary-sticky-fold)'
"
>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
/>
<ButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:disabled="
collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined
"
:icon="IconArchive"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
</span>
</div>
<div class="flex flex-col overflow-hidden">
<SmartTree :adapter="teamAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<CollectionsCollection
v-if="node.data.type === 'collections'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
collectionID: node.id,
})
"
folder-type="collection"
@add-request="
node.data.type === 'collections' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-collection',
collectionID: node.id,
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
folderID: node.data.data.data.id,
})
"
folder-type="folder"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'folders' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'folders' &&
emit('edit-folder', {
folder: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="
node.data.type === 'folders' &&
emit('remove-folder', node.data.data.data.id)
"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-folder',
folderID: node.data.data.data.id,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data.request"
:collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading"
:is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
requestID: node.data.data.data.id,
})
"
@edit-request="
node.data.type === 'requests' &&
emit('edit-request', {
requestIndex: node.data.data.data.id,
request: node.data.data.data.request,
})
"
@duplicate-request="
node.data.type === 'requests' &&
emit('duplicate-request', {
folderPath: node.data.data.parentIndex,
request: node.data.data.data.request,
})
"
@remove-request="
node.data.type === 'requests' &&
emit('remove-request', {
folderPath: null,
requestIndex: node.data.data.data.id,
})
"
@select-request="
node.data.type === 'requests' &&
selectRequest({
request: node.data.data.data.request,
requestIndex: node.data.data.data.id,
})
"
/>
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('add.new')"
/>
<ButtonSecondary
v-else
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('action.new')"
/>
<ButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</template>
</SmartTree>
</div>
</div>
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { TeamRequest } from "~/helpers/teams/TeamRequest"
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
import { cloneDeep } from "lodash-es"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useReadonlyStream } from "~/composables/stream"
import { restSaveContext$ } from "~/newstore/RESTSession"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
const props = defineProps({
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
teamCollectionList: {
type: Array as PropType<TeamCollection[]>,
default: () => [],
required: true,
},
teamLoadingCollections: {
type: Array as PropType<string[]>,
default: () => [],
required: true,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
duplicateLoading: {
type: Boolean,
default: false,
required: false,
},
picked: {
type: Object as PropType<Picked | null>,
default: null,
required: false,
},
})
const emit = defineEmits<{
(
event: "add-request",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "add-folder",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: TeamCollection
}
): void
(
event: "edit-folder",
payload: {
folder: TeamCollection
}
): void
(
event: "edit-request",
payload: {
requestIndex: string
request: HoppRESTRequest
}
): void
(
event: "duplicate-request",
payload: {
folderPath: string
request: HoppRESTRequest
}
): void
(event: "export-data", payload: TeamCollection): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
event: "remove-request",
payload: {
folderPath: string | null
requestIndex: string
}
): void
(
event: "select-request",
payload: {
request: HoppRESTRequest
requestIndex: string
isActive: boolean
folderPath?: string | undefined
}
): void
(event: "select", payload: Picked | null): void
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
(event: "display-modal-import-export"): void
}>()
const teamCollectionsList = toRef(props, "teamCollectionList")
const hasNoTeamAccess = computed(
() =>
props.collectionsType.type === "team-collections" &&
(props.collectionsType.selectedTeam === undefined ||
props.collectionsType.selectedTeam.myRole === "VIEWER")
)
const isSelected = computed(() => {
return ({
collectionID,
folderID,
requestID,
}: {
collectionID?: string | undefined
folderID?: string | undefined
requestID?: string | undefined
}) => {
if (collectionID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-collection" &&
props.picked.collectionID === collectionID
)
} else if (requestID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID
)
} else {
return (
props.picked &&
props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID
)
}
}
})
const active = useReadonlyStream(restSaveContext$, null)
const isActiveRequest = computed(() => {
return (requestID: string) => {
return pipe(
active.value,
O.fromNullable,
O.filter(
(active) =>
active.originLocation === "team-collection" &&
active.requestID === requestID
),
O.isSome
)
}
})
const selectRequest = (data: {
request: HoppRESTRequest
requestIndex: string
}) => {
const { request, requestIndex } = data
if (props.saveRequest) {
emit("select", {
pickedType: "teams-request",
requestID: requestIndex,
})
} else {
emit("select-request", {
request: request,
requestIndex: requestIndex,
isActive: isActiveRequest.value(requestIndex),
})
}
}
type TeamCollections = {
type: "collections"
data: {
parentIndex: null
data: TeamCollection
}
}
type TeamFolder = {
type: "folders"
data: {
parentIndex: string
data: TeamCollection
}
}
type TeamRequests = {
type: "requests"
data: {
parentIndex: string
data: TeamRequest
}
}
type TeamCollectionNode = TeamCollections | TeamFolder | TeamRequests
class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
constructor(public data: Ref<TeamCollection[]>) {}
findCollInTree(
tree: TeamCollection[],
targetID: string
): TeamCollection | null {
for (const coll of tree) {
// If the direct child matched, then return that
if (coll.id === targetID) return coll
// Else run it in the children
if (coll.children) {
const result = this.findCollInTree(coll.children, targetID)
if (result) return result
}
}
// If nothing matched, return null
return null
}
getChildren(id: string | null): Ref<ChildrenResult<TeamCollectionNode>> {
return computed(() => {
if (id === null) {
if (props.teamLoadingCollections.includes("root")) {
return {
status: "loading",
}
} else {
const data = this.data.value.map((item) => ({
id: item.id,
data: {
type: "collections",
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamCollections>
}
} else {
const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) {
return {
status: "loading",
}
} else {
const items = this.findCollInTree(this.data.value, parsedID)
if (items) {
const data = [
...(items.children
? items.children.map((item) => ({
id: `${id}/${item.id}`,
data: {
type: "folders",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
...(items.requests
? items.requests.map((item) => ({
id: `${id}/${item.id}`,
data: {
type: "requests",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
]
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests>
} else {
return {
status: "loaded",
data: [],
}
}
}
}
})
}
}
const teamAdapter: SmartTreeAdapter<TeamCollectionNode> =
new TeamCollectionsAdapter(teamCollectionsList)
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex flex-1">
<SmartIntersection
class="flex flex-1 flex-col"
@intersecting="onTeamSelectIntersect"
>
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions!.focus()"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('collection.select_team')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
>
<ButtonSecondary
v-if="collectionsType.selectedTeam"
:icon="IconUsers"
:label="collectionsType.selectedTeam.name"
class="flex-1 !justify-start pr-8 rounded-none"
/>
<ButtonSecondary
v-else
:label="`${t('collection.select_team')}`"
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<div
v-if="isTeamListLoading && myTeams.length === 0"
class="flex flex-col flex-1 items-center justify-center p-2"
>
<SmartSpinner class="my-2" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else-if="myTeams.length > 0" class="flex flex-col">
<SmartItem
v-for="(team, index) in myTeams"
:key="`team-${index}`"
:label="team.name"
:info-icon="
team.id === collectionsType.selectedTeam?.id
? IconDone
: undefined
"
:active-info-icon="team.id === collectionsType.selectedTeam?.id"
:icon="IconUsers"
@click="
() => {
updateSelectedTeam(team)
hide()
}
"
/>
<hr />
<SmartItem
:icon="IconPlus"
:label="t('team.create_new')"
@click="
() => {
displayTeamModalAdd(true)
hide()
}
"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center text-secondaryLight p-2"
>
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-14 h-14 mb-4"
:alt="`${t('empty.teams')}`"
/>
<span class="text-center pb-4">
{{ t("empty.teams") }}
</span>
<ButtonSecondary
:label="t('team.create_new')"
filled
outline
@click="
() => {
displayTeamModalAdd(true)
hide()
}
"
/>
</div>
</div>
</template>
</tippy>
</SmartIntersection>
</div>
</template>
<script setup lang="ts">
import IconUsers from "~icons/lucide/users"
import IconDone from "~icons/lucide/check"
import { PropType, ref } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import IconPlus from "~icons/lucide/plus"
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
defineProps({
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
myTeams: {
type: Array as PropType<GetMyTeamsQuery["myTeams"]>,
default: () => [],
required: true,
},
isTeamListLoading: {
type: Boolean,
default: false,
required: true,
},
})
const tippyActions = ref<TippyComponent | null>(null)
const emit = defineEmits<{
(e: "update-selected-team", payload: SelectedTeam): void
(e: "team-select-intersect", payload: boolean): void
(e: "display-team-modal-add", payload: boolean): void
}>()
const updateSelectedTeam = (team: SelectedTeam) => {
emit("update-selected-team", team)
}
const onTeamSelectIntersect = () => {
emit("team-select-intersect", true)
}
const displayTeamModalAdd = (display: boolean) => {
emit("display-team-modal-add", display)
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,354 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collection.name }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-request', {
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', {
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
exportCollection()
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<CollectionsMyFolder
v-for="(folder, index) in collection.folders"
:key="`folder-${index}`"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${index}`"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:is-filtered="isFiltered"
:picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/>
<CollectionsMyRequest
v-for="(request, index) in collection.requests"
:key="`request-${index}`"
:request="request"
:collection-index="collectionIndex"
:folder-index="-1"
:folder-name="collection.name"
:folder-path="`${collectionIndex}`"
:request-index="index"
:save-request="saveRequest"
:collections-type="collectionsType"
:picked="picked"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
/>
<div
v-if="
(collection.folders == undefined ||
collection.folders.length === 0) &&
(collection.requests == undefined ||
collection.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="text-center">
{{ t("empty.collection") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconCircle from "~icons/lucide/circle"
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 { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { defineComponent, ref, markRaw } from "vue"
import { moveRESTRequest } from "~/newstore/collections"
export default defineComponent({
props: {
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
saveRequest: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
},
emits: [
"select",
"expand-collection",
"add-collection",
"remove-collection",
"add-folder",
"add-request",
"edit-folder",
"edit-request",
"duplicate-request",
"remove-folder",
"remove-request",
"select-collection",
"unselect-collection",
"edit-collection",
],
setup() {
return {
colorMode: useColorMode(),
toast: useToast(),
t: useI18n(),
// Template refs
tippyActions: ref<any | null>(null),
options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null),
edit: ref<any | null>(null),
deleteAction: ref<any | null>(null),
exportAction: ref<any | null>(null),
}
},
data() {
return {
IconCircle: markRaw(IconCircle),
IconCheckCircle: markRaw(IconCheckCircle),
IconFilePlus: markRaw(IconFilePlus),
IconFolderPlus: markRaw(IconFolderPlus),
IconMoreVertical: markRaw(IconMoreVertical),
IconEdit: markRaw(IconEdit),
IconDownload: markRaw(IconDownload),
IconTrash2: markRaw(IconTrash2),
showChildren: false,
dragging: false,
selectedFolder: {},
prevCursor: "",
cursor: "",
pageNo: 0,
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "my-collection" &&
this.picked.collectionIndex === this.collectionIndex
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
exportCollection() {
const collectionJSON = JSON.stringify(this.collection)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${this.collection.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "my-collection",
collectionIndex: this.collectionIndex,
},
})
this.$emit("expand-collection", this.collection.id)
this.showChildren = !this.showChildren
},
removeCollection() {
this.$emit("remove-collection", {
collectionIndex: this.collectionIndex,
collectionID: this.collection.id,
})
},
dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveRESTRequest(folderPath, requestIndex, `${this.collectionIndex}`)
},
},
})
</script>

View File

@@ -1,340 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ folder.name ? folder.name : folder.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-request', { path: folderPath })"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-folder', { folder, path: folderPath })"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', { path: folderPath })
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-folder', {
folder,
folderIndex,
collectionIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
exportFolder()
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeFolder()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<!-- Referring to this component only (this is recursive) -->
<Folder
v-for="(subFolder, subFolderIndex) in folder.folders"
:key="`subFolder-${subFolderIndex}`"
:folder="subFolder"
:folder-index="subFolderIndex"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/>
<CollectionsMyRequest
v-for="(request, index) in folder.requests"
:key="`request-${index}`"
:request="request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
:folder-name="folder.name"
:folder-path="folderPath"
:request-index="index"
:picked="picked"
:save-request="saveRequest"
:collections-type="collectionsType"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
/>
<div
v-if="
folder.folders &&
folder.folders.length === 0 &&
folder.requests &&
folder.requests.length === 0
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconFilePlus from "~icons/lucide/file-plus"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconFolder from "~icons/lucide/folder"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { moveRESTRequest } from "~/newstore/collections"
export default defineComponent({
name: "Folder",
props: {
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
folderPath: { type: String, default: null },
saveRequest: Boolean,
isFiltered: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
},
emits: [
"add-request",
"add-folder",
"edit-folder",
"update-team",
"remove-folder",
"edit-request",
"duplicate-request",
"select",
"remove-request",
"update-team-collections",
],
setup() {
const t = useI18n()
return {
// Template refs
tippyActions: ref<any | null>(null),
options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null),
edit: ref<any | null>(null),
deleteAction: ref<any | null>(null),
exportAction: ref<any | null>(null),
t,
toast: useToast(),
colorMode: useColorMode(),
IconFilePlus,
IconFolderPlus,
IconMoreVertical,
IconEdit,
IconDownload,
IconTrash2,
}
},
data() {
return {
showChildren: false,
dragging: false,
prevCursor: "",
cursor: "",
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "my-folder" &&
this.picked.folderPath === this.folderPath
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
exportFolder() {
const folderJSON = JSON.stringify(this.folder)
const file = new Blob([folderJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${this.folder.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "my-folder",
collectionIndex: this.collectionIndex,
folderName: this.folder.name,
folderPath: this.folderPath,
},
})
this.showChildren = !this.showChildren
},
removeFolder() {
this.$emit("remove-folder", {
folder: this.folder,
folderPath: this.folderPath,
})
},
dropEvent({ dataTransfer }) {
this.dragging = !this.dragging
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveRESTRequest(folderPath, requestIndex, this.folderPath)
},
},
})
</script>

View File

@@ -1,433 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="getRequestLabelColor(request.method)"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit.$el.click()"
@keyup.d="duplicate.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeRequest()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<HttpReqChangeConfirmModal
:show="confirmChange"
@hide-modal="confirmChange = false"
@save-change="saveRequestChange"
@discard-change="discardRequestChange"
/>
<CollectionsSaveRequest
mode="rest"
:show="showSaveRequestModal"
@hide-modal="showSaveRequestModal = false"
/>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import { ref, computed } from "vue"
import {
HoppRESTRequest,
safelyExtractRESTRequest,
translateToNewRequest,
isEqualHoppRESTRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import {
getDefaultRESTRequest,
getRESTRequest,
restSaveContext$,
setRESTRequest,
setRESTSaveContext,
getRESTSaveContext,
} from "~/newstore/RESTSession"
import { editRESTRequest } from "~/newstore/collections"
import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
const props = defineProps<{
request: HoppRESTRequest
collectionIndex: number
folderIndex: number
folderName: string
requestIndex: number
saveRequest: boolean
collectionsType: object
folderPath: string
picked?: {
pickedType: string
collectionIndex: number
folderPath: string
folderName: string
requestIndex: number
}
}>()
const emit = defineEmits<{
(
e: "select",
data:
| {
picked: {
pickedType: string
collectionIndex: number
folderPath: string
folderName: string
requestIndex: number
}
}
| undefined
): void
(
e: "remove-request",
data: {
folderPath: string
requestIndex: number
}
): void
(
e: "duplicate-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string
request: HoppRESTRequest
folderPath: string
requestIndex: number
}
): void
(
e: "edit-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string
request: HoppRESTRequest
folderPath: string
requestIndex: number
}
): void
}>()
const t = useI18n()
const toast = useToast()
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
}
const confirmChange = ref(false)
const showSaveRequestModal = ref(false)
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const edit = ref<any | null>(null)
const duplicate = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const active = useReadonlyStream(restSaveContext$, null)
const isSelected = computed(
() =>
props.picked &&
props.picked.pickedType === "my-request" &&
props.picked.folderPath === props.folderPath &&
props.picked.requestIndex === props.requestIndex
)
const isActive = computed(
() =>
active.value &&
active.value.originLocation === "user-collection" &&
active.value.folderPath === props.folderPath &&
active.value.requestIndex === props.requestIndex
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
dataTransfer.setData("folderPath", props.folderPath)
dataTransfer.setData("requestIndex", props.requestIndex.toString())
}
}
const removeRequest = () => {
emit("remove-request", {
folderPath: props.folderPath,
requestIndex: props.requestIndex,
})
}
const getRequestLabelColor = (method: string) =>
requestMethodLabels[
method.toLowerCase() as keyof typeof requestMethodLabels
] || requestMethodLabels.default
const setRestReq = (request: any) => {
setRESTRequest(
cloneDeep(
safelyExtractRESTRequest(
translateToNewRequest(request),
getDefaultRESTRequest()
)
),
{
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
req: cloneDeep(request),
}
)
}
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
const selectRequest = () => {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
// If the current request is the same as the request to be loaded in, there is no data loss
const currentReq = getRESTRequest()
if (isEqualHoppRESTRequest(currentReq, props.request)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
// Check if whether user clicked the same request or not
if (!isActive.value && currentReqWithNoChange !== undefined) {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
setRESTSaveContext(null)
}
}
}
/** Save current request to the collection */
const saveRequestChange = () => {
const saveCtx = getRESTSaveContext()
saveCurrentRequest(saveCtx)
confirmChange.value = false
}
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (!isActive.value) {
setRESTSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
req: cloneDeep(props.request),
})
}
confirmChange.value = false
}
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
try {
editRESTRequest(
saveCtx.folderPath,
saveCtx.requestIndex,
getRESTRequest()
)
setRestReq(props.request)
toast.success(`${t("request.saved")}`)
} catch (e) {
setRESTSaveContext(null)
saveCurrentRequest(saveCtx)
}
} else if (saveCtx.originLocation === "team-collection") {
const req = getRESTRequest()
try {
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
toast.success(`${t("request.saved")}`)
}
})
setRestReq(props.request)
} catch (error) {
showSaveRequestModal.value = true
toast.error(`${t("error.something_went_wrong")}`)
console.error(error)
}
}
}
</script>

View File

@@ -1,408 +0,0 @@
<template>
<div class="flex flex-col">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options!.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collection.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction!.$el.click()"
@keyup.n="folderAction!.$el.click()"
@keyup.e="edit!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.x="exportAction!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="exportCollection"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<CollectionsTeamsFolder
v-for="(folder, index) in collection.children"
:key="`folder-${index}`"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${index}`"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:is-filtered="isFiltered"
:picked="picked"
:loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)"
@expand-collection="expandCollection"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<CollectionsTeamsRequest
v-for="(request, index) in collection.requests"
:key="`request-${index}`"
:request="request.request"
:collection-index="collectionIndex"
:folder-index="-1"
:folder-name="collection.name"
:request-index="request.id"
:save-request="saveRequest"
:collection-i-d="collection.id"
:collections-type="collectionsType"
:picked="picked"
@edit-request="editRequest($event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<div
v-if="loadingCollectionIDs.includes(collection.id)"
class="flex flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="
(collection.children == undefined ||
collection.children.length === 0) &&
(collection.requests == undefined ||
collection.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="text-center">
{{ t("empty.collection") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconTrash2 from "~icons/lucide/trash-2"
import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconCircle from "~icons/lucide/circle"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import * as E from "fp-ts/Either"
import {
getCompleteCollectionTree,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
import { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
export default defineComponent({
props: {
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
saveRequest: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
loadingCollectionIDs: { type: Array, default: () => [] },
},
emits: [
"edit-collection",
"add-request",
"add-folder",
"edit-folder",
"edit-request",
"remove-folder",
"select",
"remove-request",
"duplicate-request",
"expand-collection",
"remove-collection",
],
setup() {
const t = useI18n()
return {
// Template refs
tippyActions: ref<TippyComponent | null>(null),
options: ref<TippyComponent | null>(null),
requestAction: ref<typeof SmartItem | null>(null),
folderAction: ref<typeof SmartItem | null>(null),
edit: ref<typeof SmartItem | null>(null),
deleteAction: ref<typeof SmartItem | null>(null),
exportAction: ref<typeof SmartItem | null>(null),
exportLoading: ref<boolean>(false),
t,
toast: useToast(),
colorMode: useColorMode(),
IconCheckCircle,
IconCircle,
IconFilePlus,
IconFolderPlus,
IconEdit,
IconDownload,
IconTrash2,
IconMoreVertical,
}
},
data() {
return {
showChildren: false,
dragging: false,
selectedFolder: {},
prevCursor: "",
cursor: "",
pageNo: 0,
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "teams-collection" &&
this.picked.collectionID === this.collection.id
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
async exportCollection() {
this.exportLoading = true
const result = await getCompleteCollectionTree(this.collection.id)()
if (E.isLeft(result)) {
this.toast.error(this.t("error.something_went_wrong").toString())
console.log(result.left)
this.exportLoading = false
this.options!.tippy.hide()
return
}
const hoppColl = teamCollToHoppRESTColl(result.right)
const collectionJSON = JSON.stringify(hoppColl)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${hoppColl.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
this.exportLoading = false
this.options!.tippy.hide()
},
editRequest(event: any) {
this.$emit("edit-request", event)
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-collection",
collectionID: this.collection.id,
},
})
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-collection",
collectionID: this.collection.id,
},
})
this.$emit("expand-collection", this.collection.id)
this.showChildren = !this.showChildren
},
removeCollection() {
this.$emit("remove-collection", {
collectionIndex: this.collectionIndex,
collectionID: this.collection.id,
})
},
expandCollection(collectionID: string) {
this.$emit("expand-collection", collectionID)
},
async dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const requestIndex = dataTransfer.getData("requestIndex")
const moveRequestResult = await moveRESTTeamRequest(
requestIndex,
this.collection.id
)()
if (E.isLeft(moveRequestResult))
this.toast.error(`${this.t("error.something_went_wrong")}`)
},
},
})
</script>

View File

@@ -1,383 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options!.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ folder.name ? folder.name : folder.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-request', { folder, path: folderPath })"
/>
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-folder', { folder, path: folderPath })"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction!.$el.click()"
@keyup.n="folderAction!.$el.click()"
@keyup.e="edit!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.x="exportAction!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-folder', {
folder,
folderIndex,
collectionIndex,
folderPath: '',
})
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="exportFolder"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeFolder()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<!-- Referring to this component only (this is recursive) -->
<Folder
v-for="(subFolder, subFolderIndex) in folder.children"
:key="`subFolder-${subFolderIndex}`"
:folder="subFolder"
:folder-index="subFolderIndex"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked"
:loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)"
@expand-collection="expandCollection"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<CollectionsTeamsRequest
v-for="(request, index) in folder.requests"
:key="`request-${index}`"
:request="request.request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
:folder-name="folder.name"
:request-index="request.id"
:save-request="saveRequest"
:collections-type="collectionsType"
:picked="picked"
:collection-i-d="folder.id"
@edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<div
v-if="loadingCollectionIDs.includes(folder.id)"
class="flex flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="
(folder.children == undefined || folder.children.length === 0) &&
(folder.requests == undefined || folder.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconFilePlus from "~icons/lucide/file-plus"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import * as E from "fp-ts/Either"
import {
getCompleteCollectionTree,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
export default defineComponent({
name: "Folder",
props: {
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
folderPath: { type: String, default: null },
saveRequest: Boolean,
isFiltered: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
loadingCollectionIDs: { type: Array, default: () => [] },
},
emits: [
"add-request",
"add-folder",
"edit-folder",
"update-team-collections",
"edit-request",
"remove-request",
"duplicate-request",
"select",
"remove-folder",
"expand-collection",
],
setup() {
return {
// Template refs
tippyActions: ref<TippyComponent | null>(null),
options: ref<TippyComponent | null>(null),
requestAction: ref<typeof SmartItem | null>(null),
folderAction: ref<typeof SmartItem | null>(null),
edit: ref<typeof SmartItem | null>(null),
deleteAction: ref<typeof SmartItem | null>(null),
exportAction: ref<typeof SmartItem | null>(null),
exportLoading: ref<boolean>(false),
toast: useToast(),
t: useI18n(),
colorMode: useColorMode(),
IconFilePlus,
IconFolderPlus,
IconCheckCircle,
IconFolder,
IconFolderOpen,
IconMoreVertical,
IconEdit,
IconDownload,
IconTrash2,
}
},
data() {
return {
showChildren: false,
dragging: false,
prevCursor: "",
cursor: "",
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "teams-folder" &&
this.picked.folderID === this.folder.id
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
async exportFolder() {
this.exportLoading = true
const result = await getCompleteCollectionTree(this.folder.id)()
if (E.isLeft(result)) {
this.toast.error(this.t("error.something_went_wrong").toString())
console.log(result.left)
this.exportLoading = false
this.options!.tippy.hide()
return
}
const hoppColl = teamCollToHoppRESTColl(result.right)
const collectionJSON = JSON.stringify(hoppColl)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${hoppColl.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
this.exportLoading = false
this.options!.tippy.hide()
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-folder",
folderID: this.folder.id,
},
})
this.$emit("expand-collection", this.$props.folder.id)
this.showChildren = !this.showChildren
},
removeFolder() {
this.$emit("remove-folder", {
collectionsType: this.collectionsType,
folder: this.folder,
})
},
expandCollection(collectionID: number) {
this.$emit("expand-collection", collectionID)
},
async dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const requestIndex = dataTransfer.getData("requestIndex")
const moveRequestResult = await moveRESTTeamRequest(
requestIndex,
this.folder.id
)()
if (E.isLeft(moveRequestResult))
this.toast.error(`${this.t("error.something_went_wrong")}`)
},
},
})
</script>

View File

@@ -1,405 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="getRequestLabelColor(request.method)"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit.$el.click()"
@keyup.d="duplicate.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
})
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', {
request,
requestIndex,
collectionID,
})
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeRequest()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<HttpReqChangeConfirmModal
:show="confirmChange"
@hide-modal="confirmChange = false"
@save-change="saveRequestChange"
@discard-change="discardRequestChange"
/>
<CollectionsSaveRequest
mode="rest"
:show="showSaveRequestModal"
@hide-modal="showSaveRequestModal = false"
/>
</div>
</template>
<script setup lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import { ref, computed } from "vue"
import {
HoppRESTRequest,
isEqualHoppRESTRequest,
safelyExtractRESTRequest,
translateToNewRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import {
getDefaultRESTRequest,
restSaveContext$,
setRESTRequest,
setRESTSaveContext,
getRESTSaveContext,
getRESTRequest,
} from "~/newstore/RESTSession"
import { editRESTRequest } from "~/newstore/collections"
import { runMutation } from "~/helpers/backend/GQLClient"
import { Team, UpdateRequestDocument } from "~/helpers/backend/graphql"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
const props = defineProps<{
request: HoppRESTRequest
collectionIndex: number
folderIndex: number
folderName?: string
requestIndex: string
saveRequest: boolean
collectionsType: {
type: "my-collections" | "team-collections"
selectedTeam: Team | undefined
}
collectionID: string
picked?: {
pickedType: string
requestID: string
}
}>()
const emit = defineEmits<{
(
e: "select",
data:
| {
picked: {
pickedType: string
requestID: string
}
}
| undefined
): void
(
e: "remove-request",
data: {
folderPath: string | undefined
requestIndex: string
}
): void
(
e: "edit-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string | undefined
requestIndex: string
request: HoppRESTRequest
}
): void
(
e: "duplicate-request",
data: {
collectionID: number | string
requestIndex: string
request: HoppRESTRequest
}
): void
}>()
const t = useI18n()
const toast = useToast()
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
}
const confirmChange = ref(false)
const showSaveRequestModal = ref(false)
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const edit = ref<any | null>(null)
const duplicate = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const active = useReadonlyStream(restSaveContext$, null)
const isSelected = computed(
() =>
props.picked &&
props.picked.pickedType === "teams-request" &&
props.picked.requestID === props.requestIndex
)
const isActive = computed(
() =>
active.value &&
active.value.originLocation === "team-collection" &&
active.value.requestID === props.requestIndex
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
dataTransfer.setData("requestIndex", props.requestIndex)
}
}
const removeRequest = () => {
emit("remove-request", {
folderPath: props.folderName,
requestIndex: props.requestIndex,
})
}
const getRequestLabelColor = (method: string): string => {
return (
(requestMethodLabels as any)[method.toLowerCase()] ||
requestMethodLabels.default
)
}
const setRestReq = (request: HoppRESTRequest) => {
setRESTRequest(
safelyExtractRESTRequest(
translateToNewRequest(request),
getDefaultRESTRequest()
),
{
originLocation: "team-collection",
requestID: props.requestIndex,
req: request,
}
)
}
const selectRequest = () => {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "teams-request",
requestID: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
confirmChange.value = true
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
// Check if whether user clicked the same request or not
if (!isActive.value && currentReqWithNoChange) {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
setRESTSaveContext(null)
}
}
}
/** Save current request to the collection */
const saveRequestChange = () => {
const saveCtx = getRESTSaveContext()
saveCurrentRequest(saveCtx)
confirmChange.value = false
}
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (!isActive.value) {
setRESTSaveContext({
originLocation: "team-collection",
requestID: props.requestIndex,
req: props.request,
})
}
confirmChange.value = false
}
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "team-collection") {
const req = getRESTRequest()
try {
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
toast.success(`${t("request.saved")}`)
}
})
setRestReq(props.request)
} catch (error) {
showSaveRequestModal.value = true
toast.error(`${t("error.something_went_wrong")}`)
console.error(error)
}
} else if (saveCtx.originLocation === "user-collection") {
try {
editRESTRequest(
saveCtx.folderPath,
saveCtx.requestIndex,
getRESTRequest()
)
setRestReq(props.request)
toast.success(`${t("request.saved")}`)
} catch (e) {
setRESTSaveContext(null)
saveCurrentRequest(null)
}
}
}
</script>

View File

@@ -331,14 +331,25 @@ const setRestReq = (request: HoppRESTRequest | null | undefined) => {
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
const useHistory = (entry: RESTHistoryEntry) => {
const currentFullReq = getRESTRequest()
const currentReqWithNoChange = getRESTSaveContext()?.req
// checks if the current request is the same as the save context request if present
if (
currentReqWithNoChange &&
isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)
) {
props.page === "rest" && setRestReq(entry.request)
clickedHistory.value = entry
}
// Initial state trigers a popup
if (!clickedHistory.value) {
else if (!clickedHistory.value) {
clickedHistory.value = entry
confirmChange.value = true
return
}
// Checks if there are any change done in current request and the history request
if (
else if (
!isEqualHoppRESTRequest(
currentFullReq,
clickedHistory.value.request as HoppRESTRequest
@@ -347,7 +358,7 @@ const useHistory = (entry: RESTHistoryEntry) => {
clickedHistory.value = entry
confirmChange.value = true
} else {
props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
props.page === "rest" && setRestReq(entry.request)
clickedHistory.value = entry
}
}

View File

@@ -16,14 +16,15 @@
<ButtonPrimary
v-focus
:label="t('action.save')"
:loading="loading"
outline
@click="saveApiChange"
@click="saveChange"
/>
<ButtonSecondary
:label="t('action.dont_save')"
outline
filled
@click="discardApiChange"
@click="discardChange"
/>
</span>
<ButtonSecondary
@@ -43,6 +44,7 @@ const t = useI18n()
defineProps<{
show: boolean
loading?: boolean
}>()
const emit = defineEmits<{
@@ -51,11 +53,11 @@ const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const saveApiChange = () => {
const saveChange = () => {
emit("save-change")
}
const discardApiChange = () => {
const discardChange = () => {
emit("discard-change")
}

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex flex-col flex-1">
<div
v-if="rootNodes.status === 'loaded' && rootNodes.data.length > 0"
class="flex flex-col"
>
<div
v-for="rootNode in rootNodes.data"
:key="rootNode.id"
class="flex flex-col flex-1"
>
<SmartTreeBranch
:node-item="rootNode"
:adapter="adapter as SmartTreeAdapter<T>"
>
<template #default="{ node, toggleChildren, isOpen }">
<slot
name="content"
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
></slot>
</template>
<template #emptyNode="{ node }">
<slot name="emptyNode" :node="node"></slot>
</template>
</SmartTreeBranch>
</div>
</div>
<div
v-else-if="rootNodes.status === 'loading'"
class="flex flex-1 flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="rootNodes.status === 'loaded' && rootNodes.data.length === 0"
class="flex flex-col flex-1"
>
<slot name="emptyNode" :node="(null as TreeNode<T> | null)"></slot>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends any">
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
const props = defineProps<{
/**
* The adapter that will be used to fetch the tree data
* @template T The type of the data that will be stored in the tree
*/
adapter: SmartTreeAdapter<T>
}>()
const t = useI18n()
/**
* Fetch the root nodes from the adapter by passing the node id as null
*/
const rootNodes = computed(() => props.adapter.getChildren(null).value)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<slot
:node="nodeItem"
:toggle-children="toggleNodeChildren"
:is-open="isNodeOpen"
></slot>
<!-- This is a performance optimization trick -->
<!-- Once expanded, Vue will traverse through the children and expand the tree up
but when we collapse, the tree and the components are disposed. This is wasteful
and comes with performance issues if the children list is expensive to render.
Hence, here we render children only when first expanded, and after that, even if collapsed,
we just hide the children.
-->
<div v-if="childrenRendered" v-show="showChildren" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleNodeChildren"
></div>
<div
v-if="childNodes.status === 'loaded' && childNodes.data.length > 0"
class="flex flex-col flex-1 truncate"
>
<TreeBranch
v-for="childNode in childNodes.data"
:key="childNode.id"
:node-item="childNode"
:adapter="adapter"
>
<!-- The child slot is given a dynamic name in order to not break Volar -->
<template #[CHILD_SLOT_NAME]="{ node, toggleChildren, isOpen }">
<!-- Casting to help with type checking -->
<slot
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
></slot>
</template>
<template #emptyNode="{ node }">
<slot name="emptyNode" :node="node"></slot>
</template>
</TreeBranch>
</div>
<div
v-if="childNodes.status === 'loading'"
class="flex flex-1 flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="childNodes.status === 'loaded' && childNodes.data.length === 0"
class="flex flex-col flex-1"
>
<slot name="emptyNode" :node="nodeItem"></slot>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends any">
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
const props = defineProps<{
/**
* The node item that will be used to render the tree branch
* @template T The type of the data passed to the tree branch
*/
adapter: SmartTreeAdapter<T>
/**
* The node item that will be used to render the tree branch content
*/
nodeItem: TreeNode<T>
}>()
const CHILD_SLOT_NAME = "default"
const t = useI18n()
/**
* Marks whether the children on this branch were ever rendered
* See the usage inside '<template>' for more info
*/
const childrenRendered = ref(false)
const showChildren = ref(false)
const isNodeOpen = ref(false)
/**
* Fetch the child nodes from the adapter by passing the node id of the current node
*/
const childNodes = computed(
() => props.adapter.getChildren(props.nodeItem.id).value
)
const toggleNodeChildren = () => {
if (!childrenRendered.value) childrenRendered.value = true
showChildren.value = !showChildren.value
isNodeOpen.value = !isNodeOpen.value
}
</script>

View File

@@ -12,6 +12,7 @@ import { TeamCollection } from "../teams/TeamCollection"
import { TeamRequest } from "../teams/TeamRequest"
import { GQLError, runGQLQuery } from "./GQLClient"
import {
ExportAsJsonDocument,
GetCollectionChildrenIDsDocument,
GetCollectionRequestsDocument,
GetCollectionTitleDocument,
@@ -125,3 +126,23 @@ export const teamCollToHoppRESTColl = (
folders: coll.children?.map(teamCollToHoppRESTColl) ?? [],
requests: coll.requests?.map((x) => x.request) ?? [],
})
/**
* Get the JSON string of all the collection of the specified team
* @param teamID - ID of the team
* @returns Either of the JSON string of the collection or the error
*/
export const getTeamCollectionJSON = async (teamID: string) => {
const data = await runGQLQuery({
query: ExportAsJsonDocument,
variables: {
teamID,
},
})
if (E.isLeft(data)) {
return E.left(data.left)
}
return E.right(data.right)
}

View File

@@ -0,0 +1,76 @@
import { runMutation } from "../GQLClient"
import {
CreateChildCollectionDocument,
CreateChildCollectionMutation,
CreateChildCollectionMutationVariables,
CreateNewRootCollectionDocument,
CreateNewRootCollectionMutation,
CreateNewRootCollectionMutationVariables,
DeleteCollectionDocument,
DeleteCollectionMutation,
DeleteCollectionMutationVariables,
ImportFromJsonDocument,
ImportFromJsonMutation,
ImportFromJsonMutationVariables,
RenameCollectionDocument,
RenameCollectionMutation,
RenameCollectionMutationVariables,
} from "../graphql"
type CreateNewRootCollectionError = "team_coll/short_title"
type CreateChildCollectionError = "team_coll/short_title"
type RenameCollectionError = "team_coll/short_title"
type DeleteCollectionError = "team/invalid_coll_id"
export const createNewRootCollection = (title: string, teamID: string) =>
runMutation<
CreateNewRootCollectionMutation,
CreateNewRootCollectionMutationVariables,
CreateNewRootCollectionError
>(CreateNewRootCollectionDocument, {
title,
teamID,
})
export const createChildCollection = (
childTitle: string,
collectionID: string
) =>
runMutation<
CreateChildCollectionMutation,
CreateChildCollectionMutationVariables,
CreateChildCollectionError
>(CreateChildCollectionDocument, {
childTitle,
collectionID,
})
/** Can be used to rename both collection and folder (considered same in BE) */
export const renameCollection = (collectionID: string, newTitle: string) =>
runMutation<
RenameCollectionMutation,
RenameCollectionMutationVariables,
RenameCollectionError
>(RenameCollectionDocument, {
collectionID,
newTitle,
})
/** Can be used to delete both collection and folder (considered same in BE) */
export const deleteCollection = (collectionID: string) =>
runMutation<
DeleteCollectionMutation,
DeleteCollectionMutationVariables,
DeleteCollectionError
>(DeleteCollectionDocument, {
collectionID,
})
export const importJSONToTeam = (collectionJSON: string, teamID: string) =>
runMutation<ImportFromJsonMutation, ImportFromJsonMutationVariables, "">(
ImportFromJsonDocument,
{
jsonString: collectionJSON,
teamID,
}
)

View File

@@ -1,14 +1,66 @@
import { runMutation } from "../GQLClient"
import {
CreateRequestInCollectionDocument,
CreateRequestInCollectionMutation,
CreateRequestInCollectionMutationVariables,
DeleteRequestDocument,
DeleteRequestMutation,
DeleteRequestMutationVariables,
MoveRestTeamRequestDocument,
MoveRestTeamRequestMutation,
MoveRestTeamRequestMutationVariables,
UpdateRequestDocument,
UpdateRequestMutation,
UpdateRequestMutationVariables,
} from "../graphql"
type MoveRestTeamRequestErrors =
| "team_req/not_found"
| "team_req/invalid_target_id"
type DeleteRequestErrors = "team_req/not_found"
export const createRequestInCollection = (
collectionID: string,
data: {
request: string
teamID: string
title: string
}
) =>
runMutation<
CreateRequestInCollectionMutation,
CreateRequestInCollectionMutationVariables,
""
>(CreateRequestInCollectionDocument, {
collectionID,
data,
})
export const updateTeamRequest = (
requestID: string,
data: {
request: string
title: string
}
) =>
runMutation<UpdateRequestMutation, UpdateRequestMutationVariables, "">(
UpdateRequestDocument,
{
requestID,
data,
}
)
export const deleteTeamRequest = (requestID: string) =>
runMutation<
DeleteRequestMutation,
DeleteRequestMutationVariables,
DeleteRequestErrors
>(DeleteRequestDocument, {
requestID,
})
export const moveRESTTeamRequest = (requestID: string, collectionID: string) =>
runMutation<
MoveRestTeamRequestMutation,

View File

@@ -0,0 +1,34 @@
import axios from "axios"
import * as TE from "fp-ts/TaskEither"
/**
* Create an gist on GitHub with the collection JSON
* @param collectionJSON - JSON string of the collection
* @param accessToken - GitHub access token
* @returns Either of the response of the GitHub Gist API or the error
*/
export const createCollectionGists = (
collectionJSON: string,
accessToken: string
) => {
return TE.tryCatch(
async () =>
axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-collections.json": {
content: collectionJSON,
},
},
},
{
headers: {
Authorization: `token ${accessToken}`,
Accept: "application/vnd.github.v3+json",
},
}
),
(reason) => reason
)
}

View File

@@ -0,0 +1,34 @@
import { Ref } from "vue"
/**
* Representation of a tree node in the SmartTreeAdapter.
*/
export type TreeNode<T> = {
id: string
data: T
}
/**
* Representation of children result from a tree node when there will be a loading state.
*/
export type ChildrenResult<T> =
| {
status: "loading"
}
| {
status: "loaded"
data: Array<TreeNode<T>>
}
/**
* A tree adapter that can be used with the SmartTree component.
* @template T The type of data that is stored in the tree.
*/
export interface SmartTreeAdapter<T> {
/**
*
* @param nodeID - id of the node to get children for
* @returns - Ref that contains the children of the node. It is reactive and will be updated when the children are changed.
*/
getChildren: (nodeID: string | null) => Ref<ChildrenResult<T>>
}

View File

@@ -0,0 +1,47 @@
/**
* Picked is used to defrentiate
* the select item in the save request dialog
* The save request dialog can be used
* to save a request, folder or a collection
* seperately for my and teams for REST.
* also for graphQL collections
*/
export type Picked =
| {
pickedType: "my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "my-folder"
folderPath: string
}
| {
pickedType: "my-collection"
collectionIndex: number
}
| {
pickedType: "teams-request"
requestID: string
}
| {
pickedType: "teams-folder"
folderID: string
}
| {
pickedType: "teams-collection"
collectionID: string
}
| {
pickedType: "gql-my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "gql-my-folder"
folderPath: string
}
| {
pickedType: "gql-my-collection"
collectionIndex: number
}

View File

@@ -36,5 +36,9 @@
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
]
],
"vueCompilerOptions": {
"jsxTemplates": true,
"experimentalRfc436": true
}
}

View File

@@ -23,6 +23,7 @@ export default defineConfig({
lowerSecondaryStickyFold: "var(--lower-secondary-sticky-fold)",
lowerTertiaryStickyFold: "var(--lower-tertiary-sticky-fold)",
sidebarPrimaryStickyFold: "var(--sidebar-primary-sticky-fold)",
sidebarSecondaryStickyFold: "var(--line-height-body)",
},
colors: {
primary: "var(--primary-color)",

View File

@@ -1,37 +0,0 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
ButtonPrimary: typeof import('./components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./components/button/Secondary.vue')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
SmartCheckbox: typeof import('./components/smart/Checkbox.vue')['default']
SmartConfirmModal: typeof import('./components/smart/ConfirmModal.vue')['default']
SmartExpand: typeof import('./components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./components/smart/FileChip.vue')['default']
SmartIntersection: typeof import('./components/smart/Intersection.vue')['default']
SmartItem: typeof import('./components/smart/Item.vue')['default']
SmartLink: typeof import('./components/smart/Link.vue')['default']
SmartModal: typeof import('./components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./components/smart/Spinner.vue')['default']
SmartTab: typeof import('./components/smart/Tab.vue')['default']
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./components/smart/Window.vue')['default']
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
}
}

View File

@@ -63,78 +63,82 @@
</SmartLink>
</template>
<script setup lang="ts">
defineProps({
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
/**
* This will be a component!
*/
icon: {
type: Object,
default: null,
},
/**
* This will be a component!
*/
svg: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
<script lang="ts">
import { defineComponent } from "vue"
activeInfoIcon: {
type: Boolean,
default: false,
},
export default defineComponent({
props: {
to: {
type: String,
default: "",
},
exact: {
type: Boolean,
default: true,
},
blank: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
description: {
type: String,
default: "",
},
/**
* This will be a component!
*/
icon: {
type: Object,
default: null,
},
/**
* This will be a component!
*/
svg: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
shortcut: {
type: Array,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
/**
* This will be a component!
*/
infoIcon: {
type: Object,
default: null,
activeInfoIcon: {
type: Boolean,
default: false,
},
/**
* This will be a component!
*/
infoIcon: {
type: Object,
default: null,
},
},
})
</script>

View File

@@ -106,36 +106,25 @@ import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
const { t, onModalOpen, onModalClose } =
inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
defineProps({
dialog: {
type: Boolean,
default: false,
},
title: {
type: String,
default: "",
},
dimissible: {
type: Boolean,
default: true,
},
placement: {
type: String,
default: "top",
},
fullWidth: {
type: Boolean,
default: false,
},
styles: {
type: String,
default: "sm:max-w-lg",
},
closeText: {
type: String,
default: null,
},
})
withDefaults(
defineProps<{
dialog: boolean,
title: string,
dimissible: boolean,
placement: string,
fullWidth: boolean,
styles: string,
closeText: string | null,
}>(), {
dialog: false,
title: "",
dimissible: true,
placement: "top",
fullWidth: false,
styles: "sm:max-w-lg",
closeText: null
}
)
const emit = defineEmits<{
(e: "close"): void

View File

@@ -1,3 +1,9 @@
<template>
<icon-lucide-loader class="animate-spin svg-icons" />
</template>
<script lang="ts">
import { defineComponent } from "vue"
export default defineComponent({})
</script>