chore: split app to commons and web (squash commit)

This commit is contained in:
Andrew Bastin
2022-12-02 02:57:46 -05:00
parent fb827e3586
commit 3d004f2322
535 changed files with 1487 additions and 501 deletions

View File

@@ -0,0 +1,89 @@
<template>
<SmartModal
v-if="show"
dialog
:title="`${t('collection.new')}`"
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAdd"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addNewCollection"
/>
<label for="selectLabelGqlAdd">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
outline
@click="addNewCollection"
/>
<ButtonSecondary
:label="`${t('action.cancel')}`"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { HoppGQLRequest, makeCollection } from "@hoppscotch/data"
import { addGraphqlCollection } from "~/newstore/collections"
export default defineComponent({
props: {
show: Boolean,
},
emits: ["hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: null as string | null,
}
},
methods: {
addNewCollection() {
if (!this.name) {
this.toast.error(`${this.t("collection.invalid_name")}`)
return
}
addGraphqlCollection(
makeCollection<HoppGQLRequest>({
name: this.name,
folders: [],
requests: [],
})
)
this.hideModal()
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
</script>

View File

@@ -0,0 +1,82 @@
<template>
<SmartModal
v-if="show"
dialog
:title="t('folder.new')"
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAddFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addFolder"
/>
<label for="selectLabelGqlAddFolder">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary :label="t('action.save')" outline @click="addFolder" />
<ButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script lang="ts">
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { defineComponent } from "vue"
export default defineComponent({
props: {
show: Boolean,
folderPath: { type: String, default: null },
collectionIndex: { type: Number, default: null },
},
emits: ["hide-modal", "add-folder"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: null,
}
},
methods: {
addFolder() {
if (!this.name) {
this.toast.error(`${this.t("folder.name_length_insufficient")}`)
return
}
this.$emit("add-folder", {
name: this.name,
path: this.folderPath || `${this.collectionIndex}`,
})
this.hideModal()
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<SmartModal
v-if="show"
dialog
:title="t('request.new')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlAddRequest"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addRequest"
/>
<label for="selectLabelGqlAddRequest">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary :label="t('action.save')" outline @click="addRequest" />
<ButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { getGQLSession } from "~/newstore/GQLSession"
const toast = useToast()
const t = useI18n()
const props = defineProps<{
show: boolean
folderPath?: string
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-request",
v: {
name: string
path: string | undefined
}
): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (show) {
name.value = getGQLSession().request.name
}
}
)
const addRequest = () => {
if (!name.value) {
toast.error(`${t("error.empty_req_name")}`)
return
}
emit("add-request", {
name: name.value,
path: props.folderPath,
})
hideModal()
}
const hideModal = () => {
emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,303 @@
<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="collectionIcon"
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', {
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.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', {
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="`${t('action.edit')}`"
:shortcut="['E']"
@click="
() => {
emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
:shortcut="['⌫']"
@click="
() => {
confirmRemove = true
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">
<CollectionsGraphqlFolder
v-for="(folder, index) in collection.folders"
:key="`folder-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${String(index)}`"
:collection-index="collectionIndex"
:is-filtered="isFiltered"
@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)"
/>
<CollectionsGraphqlRequest
v-for="(request, index) in collection.requests"
:key="`request-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:request="request"
:collection-index="collectionIndex"
:folder-index="-1"
:folder-name="collection.name"
:folder-path="`${collectionIndex}`"
:request-index="index"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
/>
<div
v-if="
collection.folders.length === 0 && 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>
<SmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_collection')}`"
@hide-modal="confirmRemove = false"
@resolve="removeCollection"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
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 IconTrash2 from "~icons/lucide/trash-2"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import {
removeGraphqlCollection,
moveGraphqlRequest,
} from "~/newstore/collections"
const props = defineProps({
picked: { type: Object, default: null },
// Whether the viewing context is related to picking (activates 'select' events)
savingMode: { type: Boolean, default: false },
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
})
const colorMode = useColorMode()
const toast = useToast()
const t = useI18n()
// TODO: improve types plz
const emit = defineEmits<{
(e: "select", i: { picked: any }): void
(e: "edit-request", i: any): void
(e: "duplicate-request", i: any): void
(e: "add-request", i: any): void
(e: "add-folder", i: any): void
(e: "edit-folder", i: any): void
(e: "edit-collection"): void
}>()
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const requestAction = ref<any | null>(null)
const folderAction = ref<any | null>(null)
const edit = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const showChildren = ref(false)
const dragging = ref(false)
const confirmRemove = ref(false)
const isSelected = computed(
() =>
props.picked?.pickedType === "gql-my-collection" &&
props.picked?.collectionIndex === props.collectionIndex
)
const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (!showChildren.value || props.isFiltered) return IconFolderOpen
else return IconFolder
})
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-collection",
collectionIndex: props.collectionIndex,
},
})
}
const toggleShowChildren = () => {
if (props.savingMode) {
pick()
}
showChildren.value = !showChildren.value
}
const removeCollection = () => {
// Cancel pick if picked collection is deleted
if (
props.picked?.pickedType === "gql-my-collection" &&
props.picked?.collectionIndex === props.collectionIndex
) {
emit("select", { picked: null })
}
removeGraphqlCollection(props.collectionIndex)
toast.success(`${t("state.deleted")}`)
}
const dropEvent = ({ dataTransfer }: any) => {
dragging.value = !dragging.value
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveGraphqlRequest(folderPath, requestIndex, `${props.collectionIndex}`)
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<SmartModal
v-if="show"
dialog
:title="`${t('collection.edit')}`"
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEdit"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveCollection"
/>
<label for="selectLabelGqlEdit">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
outline
@click="saveCollection"
/>
<ButtonSecondary
:label="`${t('action.cancel')}`"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { editGraphqlCollection } from "~/newstore/collections"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
const props = defineProps({
show: Boolean,
editingCollection: { type: Object, default: () => ({}) },
editingCollectionIndex: { type: Number, default: null },
editingCollectionName: { type: String, default: null },
})
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const t = useI18n()
const toast = useToast()
const name = ref<string | null>()
watch(
() => props.editingCollectionName,
(val) => {
name.value = val
}
)
const saveCollection = () => {
if (!name.value) {
toast.error(`${t("collection.invalid_name")}`)
return
}
// TODO: Better typechecking here ?
const collectionUpdated = {
...(props.editingCollection as any),
name: name.value,
}
editGraphqlCollection(props.editingCollectionIndex, collectionUpdated)
hideModal()
}
const hideModal = () => {
name.value = null
emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,91 @@
<template>
<SmartModal
v-if="show"
dialog
:title="`${t('folder.edit')}`"
@close="$emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEditFolder"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="editFolder"
/>
<label for="selectLabelGqlEditFolder">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
outline
@click="editFolder"
/>
<ButtonSecondary
:label="`${t('action.cancel')}`"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { editGraphqlFolder } from "~/newstore/collections"
export default defineComponent({
props: {
show: Boolean,
folder: { type: Object, default: () => ({}) },
folderPath: { type: String, default: null },
editingFolderName: { type: String, default: null },
},
emits: ["hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: "",
}
},
watch: {
editingFolderName(val) {
this.name = val
},
},
methods: {
editFolder() {
if (!this.name) {
this.toast.error(`${this.t("collection.invalid_name")}`)
return
}
editGraphqlFolder(this.folderPath, {
...(this.folder as any),
name: this.name,
})
this.hideModal()
},
hideModal() {
this.name = ""
this.$emit("hide-modal")
},
},
})
</script>

View File

@@ -0,0 +1,100 @@
<template>
<SmartModal
v-if="show"
dialog
:title="`${t('modal.edit_request')}`"
@close="hideModal"
>
<template #body>
<div class="flex flex-col">
<input
id="selectLabelGqlEditReq"
v-model="requestUpdateData.name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveRequest"
/>
<label for="selectLabelGqlEditReq">
{{ t("action.label") }}
</label>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
outline
@click="saveRequest"
/>
<ButtonSecondary
:label="`${t('action.cancel')}`"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { HoppGQLRequest } from "@hoppscotch/data"
import { editGraphqlRequest } from "~/newstore/collections"
export default defineComponent({
props: {
show: Boolean,
folderPath: { type: String, default: null },
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
requestIndex: { type: Number, default: null },
editingRequestName: { type: String, default: null },
},
emits: ["hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
requestUpdateData: {
name: null as any | null,
},
}
},
watch: {
editingRequestName(val) {
this.requestUpdateData.name = val
},
},
methods: {
saveRequest() {
if (!this.requestUpdateData.name) {
this.toast.error(`${this.t("collection.invalid_name")}`)
return
}
// TODO: Type safety goes brrrr. Proper typing plz
const requestUpdated = {
...this.$props.request,
name: this.$data.requestUpdateData.name || this.$props.request.name,
}
editGraphqlRequest(this.folderPath, this.requestIndex, requestUpdated)
this.hideModal()
},
hideModal() {
this.requestUpdateData = { name: null }
this.$emit("hide-modal")
},
},
})
</script>

View File

@@ -0,0 +1,292 @@
<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="collectionIcon"
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.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, folderPath })
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
:shortcut="['⌫']"
@click="
() => {
confirmRemove = true
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-${String(subFolderIndex)}`"
:picked="picked"
:saving-mode="savingMode"
:folder="subFolder"
:folder-index="subFolderIndex"
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
:collection-index="collectionIndex"
:is-filtered="isFiltered"
@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)"
/>
<CollectionsGraphqlRequest
v-for="(request, index) in folder.requests"
:key="`request-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:request="request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
:folder-path="folderPath"
:folder-name="folder.name"
:request-index="index"
@edit-request="emit('edit-request', $event)"
@duplicate-request="emit('duplicate-request', $event)"
@select="emit('select', $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>
<SmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_folder')}`"
@hide-modal="confirmRemove = false"
@resolve="removeFolder"
/>
</div>
</template>
<script setup lang="ts">
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections"
import { computed, ref } from "vue"
const toast = useToast()
const t = useI18n()
const colorMode = useColorMode()
const props = defineProps({
picked: { type: Object, default: null },
// Whether the request is in a selectable mode (activates 'select' event)
savingMode: { type: Boolean, default: false },
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
folderPath: { type: String, default: null },
isFiltered: Boolean,
})
const emit = defineEmits([
"select",
"add-request",
"edit-request",
"add-folder",
"edit-folder",
"duplicate-request",
])
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const requestAction = ref<any | null>(null)
const folderAction = ref<any | null>(null)
const edit = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const showChildren = ref(false)
const dragging = ref(false)
const confirmRemove = ref(false)
const isSelected = computed(
() =>
props.picked?.pickedType === "gql-my-folder" &&
props.picked?.folderPath === props.folderPath
)
const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (showChildren.value || !props.isFiltered) return IconFolderOpen
else return IconFolder
})
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-folder",
folderPath: props.folderPath,
},
})
}
const toggleShowChildren = () => {
if (props.savingMode) {
pick()
}
showChildren.value = !showChildren.value
}
const removeFolder = () => {
// Cancel pick if the picked folder is deleted
if (
props.picked?.pickedType === "gql-my-folder" &&
props.picked?.folderPath === props.folderPath
) {
emit("select", { picked: null })
}
removeGraphqlFolder(props.folderPath)
toast.success(t("state.deleted"))
}
const dropEvent = ({ dataTransfer }: any) => {
dragging.value = !dragging.value
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveGraphqlRequest(folderPath, requestIndex, props.folderPath)
}
</script>

View File

@@ -0,0 +1,267 @@
<template>
<SmartModal
v-if="show"
dialog
:title="`${t('modal.collections')}`"
styles="sm:max-w-md"
@close="hideModal"
>
<template #actions>
<span>
<tippy interactive trigger="click" theme="popover">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
:on-shown="() => tippyActions.focus()"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
:icon="IconGithub"
:label="t('import.from_gist')"
@click="
() => {
readCollectionGist()
hide()
}
"
/>
<span
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
>
<SmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:label="t('export.create_secret_gist')"
@click="
() => {
createCollectionGist()
hide()
}
"
/>
</span>
</div>
</template>
</tippy>
</span>
</template>
<template #body>
<div class="flex flex-col space-y-2">
<SmartItem
:icon="IconFolderPlus"
:label="t('import.from_json')"
@click="openDialogChooseFileToImportFrom"
/>
<input
ref="inputChooseFileToImportFrom"
class="input"
type="file"
accept="application/json"
@change="importFromJSON"
/>
<hr />
<SmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:label="t('export.as_json')"
@click="exportJSON"
/>
</div>
</template>
</SmartModal>
</template>
<script setup lang="ts">
import axios from "axios"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { currentUser$ } from "~/helpers/fb/auth"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import {
graphqlCollections$,
setGraphqlCollections,
appendGraphqlCollections,
} from "~/newstore/collections"
defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const toast = useToast()
const t = useI18n()
const collections = useReadonlyStream(graphqlCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
// Template refs
const tippyActions = ref<any | null>(null)
const inputChooseFileToImportFrom = ref<HTMLInputElement>()
const collectionJson = computed(() => {
return JSON.stringify(collections.value, null, 2)
})
const createCollectionGist = async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission").toString())
return
}
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.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
}
}
const fileImported = () => {
toast.success(t("state.file_imported").toString())
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const readCollectionGist = async () => {
const gist = prompt(t("import.gist_url").toString())
if (!gist) return
try {
const { files } = (await axios.get(
`https://api.github.com/gists/${gist.split("/").pop()}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
},
}
)) as {
files: {
[fileName: string]: {
content: any
}
}
}
const collections = JSON.parse(Object.values(files)[0].content)
setGraphqlCollections(collections)
fileImported()
} catch (e) {
failedImport()
console.error(e)
}
}
const hideModal = () => {
emit("hide-modal")
}
const openDialogChooseFileToImportFrom = () => {
if (inputChooseFileToImportFrom.value)
inputChooseFileToImportFrom.value.click()
}
const importFromJSON = () => {
if (!inputChooseFileToImportFrom.value) return
if (
!inputChooseFileToImportFrom.value.files ||
inputChooseFileToImportFrom.value.files.length === 0
) {
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
toast.show(t("action.choose_file").toString())
return
}
const collections = JSON.parse(content)
if (collections[0]) {
const [name, folders, requests] = Object.keys(collections[0])
if (name === "name" && folders === "folders" && requests === "requests") {
// Do nothing
}
} else {
failedImport()
return
}
appendGraphqlCollections(collections)
fileImported()
}
reader.readAsText(inputChooseFileToImportFrom.value.files[0])
inputChooseFileToImportFrom.value.value = ""
}
const exportJSON = () => {
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)
}
</script>

View File

@@ -0,0 +1,222 @@
<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"
@click="selectRequest()"
>
<component
:is="isSelected ? IconCheckCircle : IconFile"
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="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="!savingMode"
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', {
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="`${t('action.duplicate')}`"
:shortcut="['D']"
@click="
() => {
$emit('duplicate-request', {
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
:shortcut="['⌫']"
@click="
() => {
confirmRemove = true
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<SmartConfirmModal
:show="confirmRemove"
:title="`${t('confirm.remove_request')}`"
@hide-modal="confirmRemove = false"
@resolve="removeRequest"
/>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFile from "~icons/lucide/file"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
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 { PropType, computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { removeGraphqlRequest } from "~/newstore/collections"
import { setGQLSession } from "~/newstore/GQLSession"
// 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 t = useI18n()
const toast = useToast()
const props = defineProps({
// Whether the object is selected (show the tick mark)
picked: { type: Object, default: null },
// Whether the request is being saved (activate 'select' event)
savingMode: { type: Boolean, default: false },
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
folderPath: { type: String, default: null },
requestIndex: { type: Number, default: null },
})
// TODO: Better types please
const emit = defineEmits(["select", "edit-request", "duplicate-request"])
const dragging = ref(false)
const confirmRemove = ref(false)
const isSelected = computed(
() =>
props.picked?.pickedType === "gql-my-request" &&
props.picked?.folderPath === props.folderPath &&
props.picked?.requestIndex === props.requestIndex
)
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-request",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
},
})
}
const selectRequest = () => {
if (props.savingMode) {
pick()
} else {
setGQLSession({
request: cloneDeep(
makeGQLRequest({
name: props.request.name,
url: props.request.url,
query: props.request.query,
headers: props.request.headers,
variables: props.request.variables,
auth: props.request.auth,
})
),
schema: "",
response: "",
})
}
}
const dragStart = ({ dataTransfer }: any) => {
dragging.value = !dragging.value
dataTransfer.setData("folderPath", props.folderPath)
dataTransfer.setData("requestIndex", props.requestIndex)
}
const removeRequest = () => {
// Cancel pick if the picked request is deleted
if (
props.picked &&
props.picked.pickedType === "gql-my-request" &&
props.picked.folderPath === props.folderPath &&
props.picked.requestIndex === props.requestIndex
) {
emit("select", { picked: null })
}
removeGraphqlRequest(props.folderPath, props.requestIndex)
toast.success(`${t("state.deleted")}`)
}
</script>

View File

@@ -0,0 +1,342 @@
<template>
<div :class="{ 'rounded border border-divider': savingMode }">
<div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y divide-dividerLight border-dividerLight"
:class="{ 'bg-primary': !savingMode }"
>
<input
v-if="showCollActions"
v-model="filterText"
type="search"
autocomplete="off"
:placeholder="t('action.search')"
class="flex px-4 py-2 bg-transparent"
/>
<div class="flex justify-between flex-1">
<ButtonSecondary
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="displayModalAdd(true)"
/>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="showCollActions"
v-tippy="{ theme: 'tooltip' }"
:title="t('modal.import_export')"
:icon="IconArchive"
@click="displayModalImportExport(true)"
/>
</div>
</div>
</div>
<div class="flex-col">
<CollectionsGraphqlCollection
v-for="(collection, index) in filteredCollections"
:key="`collection-${index}`"
:picked="picked"
:name="collection.name"
:collection-index="index"
:collection="collection"
:is-filtered="filterText.length > 0"
:saving-mode="savingMode"
@edit-collection="editCollection(collection, index)"
@add-request="addRequest($event)"
@add-folder="addFolder($event)"
@edit-folder="editFolder($event)"
@edit-request="editRequest($event)"
@duplicate-request="duplicateRequest($event)"
@select-collection="$emit('use-collection', collection)"
@select="$emit('select', $event)"
/>
</div>
<div
v-if="collections.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 my-4"
:alt="t('empty.collections')"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
outline
@click="displayModalAdd(true)"
/>
</div>
<div
v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
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>
<CollectionsGraphqlAdd
:show="showModalAdd"
@hide-modal="displayModalAdd(false)"
/>
<CollectionsGraphqlEdit
:show="showModalEdit"
:editing-collection="editingCollection"
:editing-collection-index="editingCollectionIndex"
:editing-collection-name="editingCollection ? editingCollection.name : ''"
@hide-modal="displayModalEdit(false)"
/>
<CollectionsGraphqlAddRequest
:show="showModalAddRequest"
:folder-path="editingFolderPath"
@add-request="onAddRequest($event)"
@hide-modal="displayModalAddRequest(false)"
/>
<CollectionsGraphqlAddFolder
:show="showModalAddFolder"
:folder-path="editingFolderPath"
@add-folder="onAddFolder($event)"
@hide-modal="displayModalAddFolder(false)"
/>
<CollectionsGraphqlEditFolder
:show="showModalEditFolder"
:collection-index="editingCollectionIndex"
:folder="editingFolder"
:folder-index="editingFolderIndex"
:folder-path="editingFolderPath"
:editing-folder-name="editingFolder ? editingFolder.name : ''"
@hide-modal="displayModalEditFolder(false)"
/>
<CollectionsGraphqlEditRequest
:show="showModalEditRequest"
:folder-path="editingFolderPath"
:request="editingRequest"
:request-index="editingRequestIndex"
:editing-request-name="editingRequest ? editingRequest.name : ''"
@hide-modal="displayModalEditRequest(false)"
/>
<CollectionsGraphqlImportExport
:show="showModalImportExport"
@hide-modal="displayModalImportExport(false)"
/>
</div>
</template>
<script lang="ts">
// TODO: TypeScript + Script Setup this :)
import { defineComponent } from "vue"
import { cloneDeep, clone } from "lodash-es"
import {
graphqlCollections$,
addGraphqlFolder,
saveGraphqlRequestAs,
} from "~/newstore/collections"
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconArchive from "~icons/lucide/archive"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
export default defineComponent({
props: {
// Whether to activate the ability to pick items (activates 'select' events)
savingMode: { type: Boolean, default: false },
picked: { type: Object, default: null },
// Whether to show the 'New' and 'Import/Export' actions
showCollActions: { type: Boolean, default: true },
},
emits: ["select", "use-collection"],
setup() {
const collections = useReadonlyStream(graphqlCollections$, [], "deep")
const colorMode = useColorMode()
const t = useI18n()
return {
collections,
colorMode,
t,
IconPlus,
IconHelpCircle,
IconArchive,
}
},
data() {
return {
showModalAdd: false,
showModalEdit: false,
showModalImportExport: false,
showModalAddRequest: false,
showModalAddFolder: false,
showModalEditFolder: false,
showModalEditRequest: false,
editingCollection: undefined,
editingCollectionIndex: undefined,
editingFolder: undefined,
editingFolderName: undefined,
editingFolderIndex: undefined,
editingFolderPath: undefined,
editingRequest: undefined,
editingRequestIndex: undefined,
filterText: "",
}
},
computed: {
filteredCollections() {
const collections = clone(this.collections)
if (!this.filterText) return collections
const filterText = this.filterText.toLowerCase()
const filteredCollections = []
for (const collection of collections) {
const filteredRequests = []
const filteredFolders = []
for (const request of collection.requests) {
if (request.name.toLowerCase().includes(filterText))
filteredRequests.push(request)
}
for (const folder of collection.folders) {
const filteredFolderRequests = []
for (const request of folder.requests) {
if (request.name.toLowerCase().includes(filterText))
filteredFolderRequests.push(request)
}
if (filteredFolderRequests.length > 0) {
const filteredFolder = Object.assign({}, folder)
filteredFolder.requests = filteredFolderRequests
filteredFolders.push(filteredFolder)
}
}
if (filteredRequests.length + filteredFolders.length > 0) {
const filteredCollection = Object.assign({}, collection)
filteredCollection.requests = filteredRequests
filteredCollection.folders = filteredFolders
filteredCollections.push(filteredCollection)
}
}
return filteredCollections
},
},
methods: {
displayModalAdd(shouldDisplay) {
this.showModalAdd = shouldDisplay
},
displayModalEdit(shouldDisplay) {
this.showModalEdit = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalImportExport(shouldDisplay) {
this.showModalImportExport = shouldDisplay
},
displayModalAddRequest(shouldDisplay) {
this.showModalAddRequest = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalAddFolder(shouldDisplay) {
this.showModalAddFolder = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalEditFolder(shouldDisplay) {
this.showModalEditFolder = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
displayModalEditRequest(shouldDisplay) {
this.showModalEditRequest = shouldDisplay
if (!shouldDisplay) this.resetSelectedData()
},
editCollection(collection, collectionIndex) {
this.$data.editingCollection = collection
this.$data.editingCollectionIndex = collectionIndex
this.displayModalEdit(true)
},
onAddRequest({ name, path }) {
const newRequest = {
...getGQLSession().request,
name,
}
saveGraphqlRequestAs(path, newRequest)
setGQLSession({
request: newRequest,
schema: "",
response: "",
})
this.displayModalAddRequest(false)
},
addRequest(payload) {
const { path } = payload
this.$data.editingFolderPath = path
this.displayModalAddRequest(true)
},
onAddFolder({ name, path }) {
addGraphqlFolder(name, path)
this.displayModalAddFolder(false)
},
addFolder(payload) {
const { path } = payload
this.$data.editingFolderPath = path
this.displayModalAddFolder(true)
},
editFolder(payload) {
const { folder, folderPath } = payload
this.editingFolder = folder
this.editingFolderPath = folderPath
this.displayModalEditFolder(true)
},
editRequest(payload) {
const {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
folderPath,
} = payload
this.$data.editingFolderPath = folderPath
this.$data.editingCollectionIndex = collectionIndex
this.$data.editingFolderIndex = folderIndex
this.$data.editingFolderName = folderName
this.$data.editingRequest = request
this.$data.editingRequestIndex = requestIndex
this.displayModalEditRequest(true)
},
resetSelectedData() {
this.$data.editingCollection = undefined
this.$data.editingCollectionIndex = undefined
this.$data.editingFolder = undefined
this.$data.editingFolderIndex = undefined
this.$data.editingRequest = undefined
this.$data.editingRequestIndex = undefined
},
duplicateRequest({ folderPath, request }) {
saveGraphqlRequestAs(folderPath, {
...cloneDeep(request),
name: `${request.name} - ${this.t("action.duplicate")}`,
})
},
},
})
</script>