refactor: monorepo+pnpm (removed husky)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<!-- text-green-500 -->
|
||||
<!-- text-teal-500 -->
|
||||
<!-- text-blue-500 -->
|
||||
<!-- text-indigo-500 -->
|
||||
<!-- text-purple-500 -->
|
||||
<!-- text-yellow-500 -->
|
||||
<!-- text-orange-500 -->
|
||||
<!-- text-red-500 -->
|
||||
<!-- text-pink-500 -->
|
||||
<ButtonSecondary
|
||||
v-for="(color, index) of accentColors"
|
||||
:key="`color-${index}`"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${color.charAt(0).toUpperCase()}${color.slice(1)}`"
|
||||
:class="[{ 'bg-primaryLight': color === active }]"
|
||||
class="rounded"
|
||||
icon="lens"
|
||||
:color="color"
|
||||
@click.native="setActiveColor(color)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import {
|
||||
HoppAccentColors,
|
||||
HoppAccentColor,
|
||||
applySetting,
|
||||
useSetting,
|
||||
} from "~/newstore/settings"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
accentColors: HoppAccentColors,
|
||||
active: useSetting("THEME_COLOR"),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setActiveColor(color: HoppAccentColor) {
|
||||
document.documentElement.setAttribute("data-accent", color)
|
||||
applySetting("THEME_COLOR", color)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
282
packages/hoppscotch-app/components/smart/AceEditor.vue
Normal file
282
packages/hoppscotch-app/components/smart/AceEditor.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="show-if-initialized" :class="{ initialized }">
|
||||
<pre ref="editor" :class="styles"></pre>
|
||||
<div
|
||||
v-if="provideOutline"
|
||||
class="
|
||||
bg-primaryLight
|
||||
border-t border-divider
|
||||
flex flex-nowrap flex-1
|
||||
py-1
|
||||
px-4
|
||||
bottom-0
|
||||
z-10
|
||||
sticky
|
||||
overflow-auto
|
||||
hide-scrollbar
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="(p, index) in currentPath"
|
||||
:key="`p-${index}`"
|
||||
class="
|
||||
cursor-pointer
|
||||
flex-grow-0 flex-shrink-0
|
||||
text-secondaryLight
|
||||
inline-flex
|
||||
items-center
|
||||
hover:text-secondary
|
||||
"
|
||||
>
|
||||
<span @click="onBlockClick(index)">
|
||||
{{ p }}
|
||||
</span>
|
||||
<i v-if="index + 1 !== currentPath.length" class="mx-2 material-icons">
|
||||
chevron_right
|
||||
</i>
|
||||
<tippy
|
||||
v-if="siblingDropDownIndex == index"
|
||||
ref="options"
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
arrow
|
||||
>
|
||||
<SmartItem
|
||||
v-for="(sibling, siblingIndex) in currentSibling"
|
||||
:key="`p-${index}-sibling-${siblingIndex}`"
|
||||
:label="sibling.key ? sibling.key.value : i"
|
||||
@click.native="goToSibling(sibling)"
|
||||
/>
|
||||
</tippy>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ace from "ace-builds"
|
||||
import "ace-builds/webpack-resolver"
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import jsonParse from "~/helpers/jsonParse"
|
||||
import debounce from "~/helpers/utils/debounce"
|
||||
import outline from "~/helpers/outline"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
provideOutline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
lang: {
|
||||
type: String,
|
||||
default: "json",
|
||||
},
|
||||
lint: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
initialized: false,
|
||||
editor: null,
|
||||
cacheValue: "",
|
||||
outline: outline(),
|
||||
currentPath: [],
|
||||
currentSibling: [],
|
||||
siblingDropDownIndex: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
appFontSize() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--body-font-size"
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(value) {
|
||||
if (value !== this.cacheValue) {
|
||||
this.editor.session.setValue(value, 1)
|
||||
this.cacheValue = value
|
||||
if (this.lint) this.provideLinting(value)
|
||||
}
|
||||
},
|
||||
theme() {
|
||||
this.initialized = false
|
||||
this.editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
|
||||
this.$nextTick().then(() => {
|
||||
this.initialized = true
|
||||
})
|
||||
})
|
||||
},
|
||||
lang(value) {
|
||||
this.editor.getSession().setMode(`ace/mode/${value}`)
|
||||
},
|
||||
options(value) {
|
||||
this.editor.setOptions(value)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const editor = ace.edit(this.$refs.editor, {
|
||||
mode: `ace/mode/${this.lang}`,
|
||||
...this.options,
|
||||
})
|
||||
|
||||
// Set the theme and show the editor only after it's been set to prevent FOUC.
|
||||
editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
|
||||
this.$nextTick().then(() => {
|
||||
this.initialized = true
|
||||
})
|
||||
})
|
||||
|
||||
editor.setFontSize(this.appFontSize)
|
||||
|
||||
if (this.value) editor.setValue(this.value, 1)
|
||||
|
||||
this.editor = editor
|
||||
this.cacheValue = this.value
|
||||
|
||||
if (this.lang === "json" && this.provideOutline)
|
||||
this.initOutline(this.value)
|
||||
|
||||
editor.on("change", () => {
|
||||
const content = editor.getValue()
|
||||
this.$emit("input", content)
|
||||
this.cacheValue = content
|
||||
|
||||
if (this.provideOutline) debounce(this.initOutline(content), 500)
|
||||
|
||||
if (this.lint) this.provideLinting(content)
|
||||
})
|
||||
|
||||
if (this.lang === "json" && this.provideOutline) {
|
||||
editor.session.selection.on("changeCursor", () => {
|
||||
const index = editor.session.doc.positionToIndex(
|
||||
editor.selection.getCursor(),
|
||||
0
|
||||
)
|
||||
const path = this.outline.genPath(index)
|
||||
if (path.success) {
|
||||
this.currentPath = path.res
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Disable linting, if lint prop is false
|
||||
if (this.lint) this.provideLinting(this.value)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
|
||||
methods: {
|
||||
defineTheme() {
|
||||
if (this.theme) {
|
||||
return this.theme
|
||||
}
|
||||
const strip = (str) =>
|
||||
str.replace(/#/g, "").replace(/ /g, "").replace(/"/g, "")
|
||||
return strip(
|
||||
window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--editor-theme")
|
||||
)
|
||||
},
|
||||
|
||||
provideLinting: debounce(function (code) {
|
||||
if (this.lang === "json") {
|
||||
try {
|
||||
jsonParse(code)
|
||||
this.editor.session.setAnnotations([])
|
||||
} catch (e) {
|
||||
const pos = this.editor.session
|
||||
.getDocument()
|
||||
.indexToPosition(e.start, 0)
|
||||
this.editor.session.setAnnotations([
|
||||
{
|
||||
row: pos.row,
|
||||
column: pos.column,
|
||||
text: e.message,
|
||||
type: "error",
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}, 2000),
|
||||
|
||||
onBlockClick(index) {
|
||||
if (this.siblingDropDownIndex === index) {
|
||||
this.clearSiblingList()
|
||||
} else {
|
||||
this.currentSibling = this.outline.getSiblings(index)
|
||||
if (this.currentSibling.length) this.siblingDropDownIndex = index
|
||||
}
|
||||
},
|
||||
clearSiblingList() {
|
||||
this.currentSibling = []
|
||||
this.siblingDropDownIndex = null
|
||||
},
|
||||
goToSibling(obj) {
|
||||
this.clearSiblingList()
|
||||
if (obj.start) {
|
||||
const pos = this.editor.session.doc.indexToPosition(obj.start, 0)
|
||||
if (pos) {
|
||||
this.editor.session.selection.moveCursorTo(pos.row, pos.column, true)
|
||||
this.editor.session.selection.clearSelection()
|
||||
this.editor.scrollToLine(pos.row, false, true, null)
|
||||
}
|
||||
}
|
||||
},
|
||||
initOutline: debounce(function (content) {
|
||||
if (this.lang === "json") {
|
||||
try {
|
||||
this.outline.init(content)
|
||||
|
||||
if (content[0] === "[") this.currentPath.push("[]")
|
||||
else this.currentPath.push("{}")
|
||||
} catch (e) {
|
||||
console.log("Outline error: ", e)
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.show-if-initialized {
|
||||
&.initialized {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
& > * {
|
||||
@apply transition-none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
packages/hoppscotch-app/components/smart/Anchor.vue
Normal file
76
packages/hoppscotch-app/components/smart/Anchor.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<SmartLink
|
||||
:to="`${/^\/(?!\/).*$/.test(to) ? localePath(to) : to}`"
|
||||
:exact="exact"
|
||||
:blank="blank"
|
||||
class="inline-flex items-center justify-center focus:outline-none"
|
||||
:class="[
|
||||
color
|
||||
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
|
||||
: 'hover:text-secondaryDark focus-visible:text-secondaryDark',
|
||||
{ 'opacity-75 cursor-not-allowed': disabled },
|
||||
{ 'flex-row-reverse': reverse },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="label ? (reverse ? 'ml-2' : 'mr-2') : ''"
|
||||
class="material-icons"
|
||||
>
|
||||
{{ icon }}
|
||||
</i>
|
||||
<SmartIcon
|
||||
v-if="svg"
|
||||
:name="svg"
|
||||
:class="label ? (reverse ? 'ml-2' : 'mr-2') : ''"
|
||||
class="svg-icons"
|
||||
/>
|
||||
{{ label }}
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
to: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
blank: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
svg: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
231
packages/hoppscotch-app/components/smart/AutoComplete.vue
Normal file
231
packages/hoppscotch-app/components/smart/AutoComplete.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="autocomplete-wrapper">
|
||||
<input
|
||||
ref="acInput"
|
||||
v-model="text"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder"
|
||||
:spellcheck="spellcheck"
|
||||
:autocapitalize="autocapitalize"
|
||||
:autocorrect="spellcheck"
|
||||
:class="styles"
|
||||
@input="updateSuggestions"
|
||||
@keyup="updateSuggestions"
|
||||
@click="updateSuggestions"
|
||||
@keydown="handleKeystroke"
|
||||
@change="$emit('change', $event)"
|
||||
/>
|
||||
<ul
|
||||
v-if="suggestions.length > 0 && suggestionsVisible"
|
||||
class="suggestions"
|
||||
:style="{ transform: `translate(${suggestionsOffsetLeft}px, 0)` }"
|
||||
>
|
||||
<li
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="`suggestion-${index}`"
|
||||
:class="{ active: currentSuggestionIndex === index }"
|
||||
@click.prevent="forceSuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
spellcheck: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
|
||||
autocapitalize: {
|
||||
type: String,
|
||||
default: "off",
|
||||
required: false,
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
|
||||
source: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: false,
|
||||
},
|
||||
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
text: this.value,
|
||||
selectionStart: 0,
|
||||
suggestionsOffsetLeft: 0,
|
||||
currentSuggestionIndex: -1,
|
||||
suggestionsVisible: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Gets the suggestions list to be displayed under the input box.
|
||||
*
|
||||
* @returns {default.props.source|{type, required}}
|
||||
*/
|
||||
suggestions() {
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
|
||||
return (
|
||||
this.source
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().startsWith(input.toLowerCase()) &&
|
||||
input.toLowerCase() !== entry.toLowerCase()
|
||||
)
|
||||
// Cut off the part that's already been typed.
|
||||
.map((entry) => entry.substring(this.selectionStart))
|
||||
// We only want the top 6 suggestions.
|
||||
.slice(0, 6)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
text() {
|
||||
this.$emit("input", this.text)
|
||||
},
|
||||
value(newValue) {
|
||||
this.text = newValue
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSuggestions({
|
||||
target: this.$refs.acInput,
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSuggestions(event) {
|
||||
// Hide suggestions if ESC pressed.
|
||||
if (event.code && event.code === "Escape") {
|
||||
event.preventDefault()
|
||||
this.suggestionsVisible = false
|
||||
this.currentSuggestionIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
// As suggestions is a reactive property, this implicitly
|
||||
// causes suggestions to update.
|
||||
this.selectionStart = this.$refs.acInput.selectionStart
|
||||
this.suggestionsOffsetLeft = 12 * this.selectionStart
|
||||
this.suggestionsVisible = true
|
||||
},
|
||||
|
||||
forceSuggestion(text) {
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
this.text = input + text
|
||||
|
||||
this.selectionStart = this.text.length
|
||||
this.suggestionsVisible = true
|
||||
this.currentSuggestionIndex = -1
|
||||
},
|
||||
|
||||
handleKeystroke(event) {
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
this.currentSuggestionIndex =
|
||||
this.currentSuggestionIndex - 1 >= 0
|
||||
? this.currentSuggestionIndex - 1
|
||||
: 0
|
||||
break
|
||||
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
this.currentSuggestionIndex =
|
||||
this.currentSuggestionIndex < this.suggestions.length - 1
|
||||
? this.currentSuggestionIndex + 1
|
||||
: this.suggestions.length - 1
|
||||
break
|
||||
|
||||
case "Tab": {
|
||||
const activeSuggestion =
|
||||
this.suggestions[
|
||||
this.currentSuggestionIndex >= 0 ? this.currentSuggestionIndex : 0
|
||||
]
|
||||
|
||||
if (!activeSuggestion) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const input = this.text.substring(0, this.selectionStart)
|
||||
this.text = input + activeSuggestion
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.autocomplete-wrapper {
|
||||
@apply relative;
|
||||
@apply contents;
|
||||
|
||||
input:focus + ul.suggestions,
|
||||
ul.suggestions:hover {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
ul.suggestions {
|
||||
@apply hidden;
|
||||
@apply bg-popover;
|
||||
@apply absolute;
|
||||
@apply mx-2;
|
||||
@apply left-0;
|
||||
@apply z-50;
|
||||
@apply shadow-lg;
|
||||
|
||||
top: calc(100% - 4px);
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
li {
|
||||
@apply w-full;
|
||||
@apply block;
|
||||
@apply py-2 px-4;
|
||||
@apply text-secondary;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-accent;
|
||||
@apply text-accentContrast;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
packages/hoppscotch-app/components/smart/ChangeLanguage.vue
Normal file
30
packages/hoppscotch-app/components/smart/ChangeLanguage.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<span class="inline-flex">
|
||||
<tippy ref="language" interactive trigger="click" theme="popover" arrow>
|
||||
<template #trigger>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('settings.choose_language')"
|
||||
class="pr-8"
|
||||
outline
|
||||
svg="globe"
|
||||
:label="`${
|
||||
$i18n.locales.find(({ code }) => code == $i18n.locale).name
|
||||
}`"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-for="(locale, index) in $i18n.locales.filter(
|
||||
({ code }) => code !== $i18n.locale
|
||||
)"
|
||||
:key="`locale-${index}`"
|
||||
:to="switchLocalePath(locale.code)"
|
||||
@click="$refs.language.tippy().hide()"
|
||||
>
|
||||
<SmartItem :label="locale.name" />
|
||||
</NuxtLink>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
68
packages/hoppscotch-app/components/smart/ColorModePicker.vue
Normal file
68
packages/hoppscotch-app/components/smart/ColorModePicker.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-for="(color, index) of colors"
|
||||
:key="`color-${index}`"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t(getColorModeName(color))"
|
||||
:class="{
|
||||
'bg-primaryLight !text-accent hover:text-accent': color === active,
|
||||
}"
|
||||
class="rounded"
|
||||
:svg="getIcon(color)"
|
||||
@click.native="setBGMode(color)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import {
|
||||
applySetting,
|
||||
HoppBgColor,
|
||||
HoppBgColors,
|
||||
useSetting,
|
||||
} from "~/newstore/settings"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
colors: HoppBgColors,
|
||||
active: useSetting("BG_COLOR"),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setBGMode(color: HoppBgColor) {
|
||||
applySetting("BG_COLOR", color)
|
||||
},
|
||||
getIcon(color: HoppBgColor) {
|
||||
switch (color) {
|
||||
case "system":
|
||||
return "monitor"
|
||||
case "light":
|
||||
return "sun"
|
||||
case "dark":
|
||||
return "cloud"
|
||||
case "black":
|
||||
return "moon"
|
||||
default:
|
||||
return "monitor"
|
||||
}
|
||||
},
|
||||
getColorModeName(colorMode: string) {
|
||||
switch (colorMode) {
|
||||
case "system":
|
||||
return "settings.system_mode"
|
||||
case "light":
|
||||
return "settings.light_mode"
|
||||
case "dark":
|
||||
return "settings.dark_mode"
|
||||
case "black":
|
||||
return "settings.black_mode"
|
||||
default:
|
||||
return "settings.system_mode"
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
54
packages/hoppscotch-app/components/smart/ConfirmModal.vue
Normal file
54
packages/hoppscotch-app/components/smart/ConfirmModal.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="$t('modal.confirm')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col px-2">
|
||||
<label>
|
||||
{{ title }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span>
|
||||
<ButtonPrimary v-focus :label="yes" @click.native="resolve" />
|
||||
<ButtonSecondary :label="no" @click.native="hideModal" />
|
||||
</span>
|
||||
</template>
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
title: { type: String, default: null },
|
||||
yes: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t("action.yes")
|
||||
},
|
||||
},
|
||||
no: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t("action.no")
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hideModal() {
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
resolve() {
|
||||
this.$emit("resolve")
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
28
packages/hoppscotch-app/components/smart/DeletableChip.vue
Normal file
28
packages/hoppscotch-app/components/smart/DeletableChip.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<span class="chip">
|
||||
<i class="opacity-75 material-icons">attachment</i>
|
||||
<span class="max-w-64 px-2 truncate"><slot></slot></span>
|
||||
<ButtonSecondary
|
||||
class="rounded close-button"
|
||||
svg="x"
|
||||
@click.native="$emit('chip-delete')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chip {
|
||||
@apply inline-flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply rounded;
|
||||
@apply pl-2;
|
||||
@apply pr-0.5;
|
||||
@apply bg-transparent;
|
||||
@apply border border-divider;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply p-0.5;
|
||||
}
|
||||
</style>
|
||||
510
packages/hoppscotch-app/components/smart/EnvInput.vue
Normal file
510
packages/hoppscotch-app/components/smart/EnvInput.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<!--
|
||||
This code is a complete adaptation of the work done here
|
||||
https://github.com/SyedWasiHaider/vue-highlightable-input
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="env-input-container">
|
||||
<div
|
||||
ref="editor"
|
||||
:placeholder="placeholder"
|
||||
class="env-input"
|
||||
:class="styles"
|
||||
contenteditable="true"
|
||||
@keydown.enter.prevent="$emit('enter', $event)"
|
||||
@keyup="$emit('keyup', $event)"
|
||||
@click="$emit('click', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import IntervalTree from "node-interval-tree"
|
||||
import debounce from "lodash/debounce"
|
||||
import isUndefined from "lodash/isUndefined"
|
||||
import { tippy } from "vue-tippy"
|
||||
import { aggregateEnvs$ } from "~/newstore/environments"
|
||||
import { useReadonlyStream } from "~/helpers/utils/composables"
|
||||
|
||||
const tagsToReplace = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const aggregateEnvs = useReadonlyStream(aggregateEnvs$)
|
||||
return {
|
||||
aggregateEnvs,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
internalValue: "",
|
||||
htmlOutput: "",
|
||||
debouncedHandler: null,
|
||||
highlight: [
|
||||
{
|
||||
text: /(<<\w+>>)/g,
|
||||
style:
|
||||
"cursor-help transition rounded px-1 focus:outline-none mx-0.5",
|
||||
},
|
||||
],
|
||||
highlightEnabled: true,
|
||||
highlightStyle: "",
|
||||
caseSensitive: true,
|
||||
fireOn: "keydown",
|
||||
fireOnEnabled: true,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
aggregateEnvs() {
|
||||
this.processHighlights()
|
||||
},
|
||||
highlightStyle() {
|
||||
this.processHighlights()
|
||||
},
|
||||
highlight() {
|
||||
this.processHighlights()
|
||||
},
|
||||
value() {
|
||||
if (this.internalValue !== this.value) {
|
||||
this.internalValue = this.value
|
||||
this.processHighlights()
|
||||
}
|
||||
},
|
||||
highlightEnabled() {
|
||||
this.processHighlights()
|
||||
},
|
||||
caseSensitive() {
|
||||
this.processHighlights()
|
||||
},
|
||||
htmlOutput() {
|
||||
const selection = this.saveSelection(this.$refs.editor)
|
||||
this.$refs.editor.innerHTML = this.htmlOutput
|
||||
this.restoreSelection(this.$refs.editor, selection)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.fireOnEnabled)
|
||||
this.$refs.editor.addEventListener(this.fireOn, this.handleChange)
|
||||
this.internalValue = this.value
|
||||
this.processHighlights()
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleChange() {
|
||||
this.debouncedHandler = debounce(function () {
|
||||
if (this.internalValue !== this.$refs.editor.textContent) {
|
||||
this.internalValue = this.$refs.editor.textContent
|
||||
this.processHighlights()
|
||||
}
|
||||
}, 5)
|
||||
this.debouncedHandler()
|
||||
},
|
||||
processHighlights() {
|
||||
if (!this.highlightEnabled) {
|
||||
this.htmlOutput = this.internalValue
|
||||
if (this.intervalTree !== this.value) {
|
||||
this.$emit("input", this.internalValue)
|
||||
this.$emit("change", this.internalValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const intervalTree = new IntervalTree()
|
||||
|
||||
let highlightPositions = []
|
||||
const sortedHighlights = this.normalizedHighlights()
|
||||
|
||||
if (!sortedHighlights) return
|
||||
|
||||
for (let i = 0; i < sortedHighlights.length; i++) {
|
||||
const highlightObj = sortedHighlights[i]
|
||||
let indices = []
|
||||
|
||||
if (highlightObj.text) {
|
||||
if (typeof highlightObj.text === "string") {
|
||||
indices = this.getIndicesOf(
|
||||
highlightObj.text,
|
||||
this.internalValue,
|
||||
isUndefined(highlightObj.caseSensitive)
|
||||
? this.caseSensitive
|
||||
: highlightObj.caseSensitive
|
||||
)
|
||||
indices.forEach((start) => {
|
||||
const end = start + highlightObj.text.length - 1
|
||||
this.insertRange(start, end, highlightObj, intervalTree)
|
||||
})
|
||||
}
|
||||
if (
|
||||
Object.prototype.toString.call(highlightObj.text) ===
|
||||
"[object RegExp]"
|
||||
) {
|
||||
indices = this.getRegexIndices(
|
||||
highlightObj.text,
|
||||
this.internalValue
|
||||
)
|
||||
indices.forEach((pair) => {
|
||||
this.insertRange(pair.start, pair.end, highlightObj, intervalTree)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
highlightObj.start !== undefined &&
|
||||
highlightObj.end !== undefined &&
|
||||
highlightObj.start < highlightObj.end
|
||||
) {
|
||||
const start = highlightObj.start
|
||||
const end = highlightObj.end - 1
|
||||
|
||||
this.insertRange(start, end, highlightObj, intervalTree)
|
||||
}
|
||||
}
|
||||
|
||||
highlightPositions = intervalTree.search(0, this.internalValue.length)
|
||||
highlightPositions = highlightPositions.sort((a, b) => a.start - b.start)
|
||||
|
||||
let result = ""
|
||||
let startingPosition = 0
|
||||
|
||||
for (let k = 0; k < highlightPositions.length; k++) {
|
||||
const position = highlightPositions[k]
|
||||
result += this.safe_tags_replace(
|
||||
this.internalValue.substring(startingPosition, position.start)
|
||||
)
|
||||
const envVar = this.internalValue
|
||||
.substring(position.start, position.end + 1)
|
||||
.slice(2, -2)
|
||||
result += `<span class="${highlightPositions[k].style} ${
|
||||
this.aggregateEnvs.find((k) => k.key === envVar)?.value === undefined
|
||||
? "bg-red-400 text-red-50 hover:bg-red-600"
|
||||
: "bg-accentDark text-accentContrast hover:bg-accent"
|
||||
}" v-tippy data-tippy-content="${this.getEnvName(
|
||||
this.aggregateEnvs.find((k) => k.key === envVar)?.sourceEnv
|
||||
)} <kbd>${this.getEnvValue(
|
||||
this.aggregateEnvs.find((k) => k.key === envVar)?.value
|
||||
)}</kbd>">${this.safe_tags_replace(
|
||||
this.internalValue.substring(position.start, position.end + 1)
|
||||
)}</span>`
|
||||
startingPosition = position.end + 1
|
||||
}
|
||||
if (startingPosition < this.internalValue.length)
|
||||
result += this.safe_tags_replace(
|
||||
this.internalValue.substring(
|
||||
startingPosition,
|
||||
this.internalValue.length
|
||||
)
|
||||
)
|
||||
if (result[result.length - 1] === " ") {
|
||||
result = result.substring(0, result.length - 1)
|
||||
result += " "
|
||||
}
|
||||
this.htmlOutput = result
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.renderTippy()
|
||||
})
|
||||
|
||||
if (this.internalValue !== this.value) {
|
||||
this.$emit("input", this.internalValue)
|
||||
this.$emit("change", this.internalValue)
|
||||
}
|
||||
},
|
||||
renderTippy() {
|
||||
const tippable = document.querySelectorAll("[v-tippy]")
|
||||
tippable.forEach((t) => {
|
||||
tippy(t, {
|
||||
content: t.dataset["tippy-content"],
|
||||
theme: "tooltip",
|
||||
popperOptions: {
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
enabled: false,
|
||||
},
|
||||
hide: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
insertRange(start, end, highlightObj, intervalTree) {
|
||||
const overlap = intervalTree.search(start, end)
|
||||
const maxLengthOverlap = overlap.reduce((max, o) => {
|
||||
return Math.max(o.end - o.start, max)
|
||||
}, 0)
|
||||
if (overlap.length === 0) {
|
||||
intervalTree.insert(start, end, {
|
||||
start,
|
||||
end,
|
||||
style: highlightObj.style,
|
||||
})
|
||||
} else if (end - start > maxLengthOverlap) {
|
||||
overlap.forEach((o) => {
|
||||
intervalTree.remove(o.start, o.end, o)
|
||||
})
|
||||
intervalTree.insert(start, end, {
|
||||
start,
|
||||
end,
|
||||
style: highlightObj.style,
|
||||
})
|
||||
}
|
||||
},
|
||||
normalizedHighlights() {
|
||||
if (this.highlight == null) return null
|
||||
if (
|
||||
Object.prototype.toString.call(this.highlight) === "[object RegExp]" ||
|
||||
typeof this.highlight === "string"
|
||||
)
|
||||
return [{ text: this.highlight }]
|
||||
|
||||
if (
|
||||
Object.prototype.toString.call(this.highlight) === "[object Array]" &&
|
||||
this.highlight.length > 0
|
||||
) {
|
||||
const globalDefaultStyle =
|
||||
typeof this.highlightStyle === "string"
|
||||
? this.highlightStyle
|
||||
: Object.keys(this.highlightStyle)
|
||||
.map((key) => key + ":" + this.highlightStyle[key])
|
||||
.join(";") + ";"
|
||||
|
||||
const regExpHighlights = this.highlight.filter(
|
||||
(x) => (x === Object.prototype.toString.call(x)) === "[object RegExp]"
|
||||
)
|
||||
const nonRegExpHighlights = this.highlight.filter(
|
||||
(x) => (x === Object.prototype.toString.call(x)) !== "[object RegExp]"
|
||||
)
|
||||
return nonRegExpHighlights
|
||||
.map((h) => {
|
||||
if (h.text || typeof h === "string") {
|
||||
return {
|
||||
text: h.text || h,
|
||||
style: h.style || globalDefaultStyle,
|
||||
caseSensitive: h.caseSensitive,
|
||||
}
|
||||
} else if (h.start !== undefined && h.end !== undefined) {
|
||||
return {
|
||||
style: h.style || globalDefaultStyle,
|
||||
start: h.start,
|
||||
end: h.end,
|
||||
caseSensitive: h.caseSensitive,
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"Please provide a valid highlight object or string"
|
||||
)
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.text && b.text
|
||||
? a.text > b.text
|
||||
: a.start === b.start
|
||||
? a.end < b.end
|
||||
: a.start < b.start
|
||||
)
|
||||
.concat(regExpHighlights)
|
||||
}
|
||||
console.error("Expected a string or an array of strings")
|
||||
return null
|
||||
},
|
||||
safe_tags_replace(str) {
|
||||
return str.replace(/[&<>]/g, this.replaceTag)
|
||||
},
|
||||
replaceTag(tag) {
|
||||
return tagsToReplace[tag] || tag
|
||||
},
|
||||
getRegexIndices(regex, str) {
|
||||
if (!regex.global) {
|
||||
console.error("Expected " + regex + " to be global")
|
||||
return []
|
||||
}
|
||||
|
||||
regex = RegExp(regex)
|
||||
const indices = []
|
||||
let match = null
|
||||
|
||||
while ((match = regex.exec(str)) != null) {
|
||||
indices.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length - 1,
|
||||
})
|
||||
}
|
||||
return indices
|
||||
},
|
||||
getIndicesOf(searchStr, str, caseSensitive) {
|
||||
const searchStrLen = searchStr.length
|
||||
|
||||
if (searchStrLen === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
let index
|
||||
const indices = []
|
||||
|
||||
if (!caseSensitive) {
|
||||
str = str.toLowerCase()
|
||||
searchStr = searchStr.toLowerCase()
|
||||
}
|
||||
while ((index = str.indexOf(searchStr, startIndex)) > -1) {
|
||||
indices.push(index)
|
||||
startIndex = index + searchStrLen
|
||||
}
|
||||
return indices
|
||||
},
|
||||
saveSelection(containerEl) {
|
||||
let start
|
||||
|
||||
if (window.getSelection && document.createRange) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return
|
||||
const range = selection.getRangeAt(0)
|
||||
const preSelectionRange = range.cloneRange()
|
||||
preSelectionRange.selectNodeContents(containerEl)
|
||||
preSelectionRange.setEnd(range.startContainer, range.startOffset)
|
||||
start = preSelectionRange.toString().length
|
||||
return {
|
||||
start,
|
||||
end: start + range.toString().length,
|
||||
}
|
||||
} else if (document.selection) {
|
||||
const selectedTextRange = document.selection.createRange()
|
||||
const preSelectionTextRange = document.body.createTextRange()
|
||||
preSelectionTextRange.moveToElementText(containerEl)
|
||||
preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange)
|
||||
start = preSelectionTextRange.text.length
|
||||
return {
|
||||
start,
|
||||
end: start + selectedTextRange.text.length,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Copied but modifed slightly from: https://stackoverflow.com/questions/14636218/jquery-convert-text-url-to-link-as-typing/14637351#14637351
|
||||
restoreSelection(containerEl, savedSel) {
|
||||
if (!savedSel) return
|
||||
if (window.getSelection && document.createRange) {
|
||||
let charIndex = 0
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(containerEl, 0)
|
||||
range.collapse(true)
|
||||
|
||||
const nodeStack = [containerEl]
|
||||
let node
|
||||
let foundStart = false
|
||||
let stop = false
|
||||
|
||||
while (!stop && (node = nodeStack.pop())) {
|
||||
if (node.nodeType === 3) {
|
||||
const nextCharIndex = charIndex + node.length
|
||||
if (
|
||||
!foundStart &&
|
||||
savedSel.start >= charIndex &&
|
||||
savedSel.start <= nextCharIndex
|
||||
) {
|
||||
range.setStart(node, savedSel.start - charIndex)
|
||||
foundStart = true
|
||||
}
|
||||
if (
|
||||
foundStart &&
|
||||
savedSel.end >= charIndex &&
|
||||
savedSel.end <= nextCharIndex
|
||||
) {
|
||||
range.setEnd(node, savedSel.end - charIndex)
|
||||
stop = true
|
||||
}
|
||||
charIndex = nextCharIndex
|
||||
} else {
|
||||
let i = node.childNodes.length
|
||||
while (i--) {
|
||||
nodeStack.push(node.childNodes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
} else if (document.selection) {
|
||||
const textRange = document.body.createTextRange()
|
||||
textRange.moveToElementText(containerEl)
|
||||
textRange.collapse(true)
|
||||
textRange.moveEnd("character", savedSel.end)
|
||||
textRange.moveStart("character", savedSel.start)
|
||||
textRange.select()
|
||||
}
|
||||
},
|
||||
getEnvName(name) {
|
||||
if (name) return name
|
||||
return "choose an environment"
|
||||
},
|
||||
getEnvValue(value) {
|
||||
if (value) return value
|
||||
return "not found"
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.env-input-container {
|
||||
@apply relative;
|
||||
@apply inline-grid;
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
[contenteditable] {
|
||||
@apply select-text;
|
||||
@apply text-secondaryDark;
|
||||
@apply font-medium;
|
||||
|
||||
&:empty {
|
||||
line-height: 1.9;
|
||||
|
||||
&::before {
|
||||
@apply text-secondaryDark;
|
||||
@apply opacity-25;
|
||||
@apply pointer-events-none;
|
||||
|
||||
content: attr(placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.env-input {
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-items-start;
|
||||
@apply whitespace-nowrap;
|
||||
@apply overflow-x-auto;
|
||||
@apply overflow-y-hidden;
|
||||
@apply resize-none;
|
||||
@apply focus:outline-none;
|
||||
@apply transition;
|
||||
}
|
||||
|
||||
.env-input::-webkit-scrollbar {
|
||||
@apply hidden;
|
||||
}
|
||||
</style>
|
||||
57
packages/hoppscotch-app/components/smart/FontSizePicker.vue
Normal file
57
packages/hoppscotch-app/components/smart/FontSizePicker.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<span class="inline-flex">
|
||||
<tippy ref="fontSize" interactive trigger="click" theme="popover" arrow>
|
||||
<template #trigger>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="$t('settings.change_font_size')"
|
||||
class="pr-8"
|
||||
svg="type"
|
||||
outline
|
||||
:label="getFontSizeName(fontSizes.find((size) => size == active))"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<SmartItem
|
||||
v-for="(size, index) in fontSizes"
|
||||
:key="`size-${index}`"
|
||||
:label="getFontSizeName(size)"
|
||||
:info-icon="size === active ? 'done' : ''"
|
||||
:active-info-icon="size === active"
|
||||
@click.native="
|
||||
setActiveFont(size)
|
||||
$refs.fontSize.tippy().hide()
|
||||
"
|
||||
/>
|
||||
</tippy>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import {
|
||||
HoppFontSizes,
|
||||
HoppFontSize,
|
||||
applySetting,
|
||||
useSetting,
|
||||
} from "~/newstore/settings"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
fontSizes: HoppFontSizes,
|
||||
active: useSetting("FONT_SIZE"),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFontSizeName(size: HoppFontSize) {
|
||||
return this.$t(`settings.font_size_${size}`)
|
||||
},
|
||||
setActiveFont(size: HoppFontSize) {
|
||||
document.documentElement.setAttribute("data-font-size", size)
|
||||
applySetting("FONT_SIZE", size)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
21
packages/hoppscotch-app/components/smart/Icon.vue
Normal file
21
packages/hoppscotch-app/components/smart/Icon.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<component :is="src" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
src() {
|
||||
return require(`~/assets/icons/${this.name}.svg?inline`)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
36
packages/hoppscotch-app/components/smart/Intersection.vue
Normal file
36
packages/hoppscotch-app/components/smart/Intersection.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div ref="container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
/*
|
||||
Implements a wrapper listening to viewport intersections via
|
||||
IntesectionObserver API
|
||||
|
||||
Events
|
||||
------
|
||||
intersecting (entry: IntersectionObserverEntry) -> When the component is intersecting the viewport
|
||||
*/
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
observer: null as IntersectionObserver | null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry && entry.isIntersecting) {
|
||||
this.$emit("intersecting", entry)
|
||||
}
|
||||
})
|
||||
|
||||
this.observer.observe(this.$refs.container as Element)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.observer?.disconnect()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
142
packages/hoppscotch-app/components/smart/Item.vue
Normal file
142
packages/hoppscotch-app/components/smart/Item.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<SmartLink
|
||||
:to="`${/^\/(?!\/).*$/.test(to) ? localePath(to) : to}`"
|
||||
:exact="exact"
|
||||
:blank="blank"
|
||||
class="
|
||||
rounded
|
||||
transition
|
||||
font-medium
|
||||
flex-shrink-0
|
||||
py-2
|
||||
px-4
|
||||
inline-flex
|
||||
items-center
|
||||
hover:bg-primaryDark hover:text-secondaryDark
|
||||
focus:outline-none
|
||||
focus-visible:bg-primaryDark focus-visible:text-secondaryDark
|
||||
"
|
||||
:class="[
|
||||
{ 'opacity-75 cursor-not-allowed': disabled },
|
||||
{ 'pointer-events-none': loading },
|
||||
{ 'flex-1': label },
|
||||
{ 'flex-row-reverse justify-end': reverse },
|
||||
{
|
||||
'border border-divider hover:border-dividerDark focus-visible:border-dividerDark':
|
||||
outline,
|
||||
},
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:tabindex="loading ? '-1' : '0'"
|
||||
>
|
||||
<span
|
||||
v-if="!loading"
|
||||
class="inline-flex items-center"
|
||||
:class="{ 'self-start': infoIcon }"
|
||||
>
|
||||
<i
|
||||
v-if="icon"
|
||||
:class="[
|
||||
label ? (reverse ? 'ml-4 opacity-75' : 'mr-4 opacity-75') : '',
|
||||
{ 'text-accent': active },
|
||||
]"
|
||||
class="material-icons"
|
||||
>
|
||||
{{ icon }}
|
||||
</i>
|
||||
<SmartIcon
|
||||
v-if="svg"
|
||||
:name="svg"
|
||||
:class="[
|
||||
label ? (reverse ? 'ml-4 opacity-75' : 'mr-4 opacity-75') : '',
|
||||
{ 'text-accent': active },
|
||||
]"
|
||||
class="svg-icons"
|
||||
/>
|
||||
</span>
|
||||
<SmartSpinner v-else class="mr-4" />
|
||||
<div
|
||||
class="flex-1 inline-flex truncate items-start"
|
||||
:class="{ 'flex-col': description }"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ label }}
|
||||
</div>
|
||||
<p v-if="description" class="my-2 text-left text-secondaryLight">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<i
|
||||
v-if="infoIcon"
|
||||
class="ml-6 self-center material-icons items-center"
|
||||
:class="{ 'text-accent': activeInfoIcon }"
|
||||
>
|
||||
{{ infoIcon }}
|
||||
</i>
|
||||
</SmartLink>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
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: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
svg: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activeInfoIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
infoIcon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
292
packages/hoppscotch-app/components/smart/JsEditor.vue
Normal file
292
packages/hoppscotch-app/components/smart/JsEditor.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="show-if-initialized" :class="{ initialized }">
|
||||
<pre ref="editor" :class="styles"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ace from "ace-builds"
|
||||
import "ace-builds/webpack-resolver"
|
||||
import "ace-builds/src-noconflict/ext-language_tools"
|
||||
import "ace-builds/src-noconflict/mode-graphqlschema"
|
||||
import * as esprima from "esprima"
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import debounce from "~/helpers/utils/debounce"
|
||||
import {
|
||||
getPreRequestScriptCompletions,
|
||||
getTestScriptCompletions,
|
||||
performPreRequestLinting,
|
||||
performTestLinting,
|
||||
} from "~/helpers/tern"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
completeMode: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: "none",
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
initialized: false,
|
||||
editor: null,
|
||||
cacheValue: "",
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
appFontSize() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--body-font-size"
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
value(value) {
|
||||
if (value !== this.cacheValue) {
|
||||
this.editor.session.setValue(value, 1)
|
||||
this.cacheValue = value
|
||||
if (this.lint) this.provideLinting(value)
|
||||
}
|
||||
},
|
||||
theme() {
|
||||
this.initialized = false
|
||||
this.editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
|
||||
this.$nextTick()
|
||||
.then(() => {
|
||||
this.initialized = true
|
||||
})
|
||||
.catch(() => {
|
||||
// nextTick shouldn't really ever throw but still
|
||||
this.initialized = true
|
||||
})
|
||||
})
|
||||
},
|
||||
options(value) {
|
||||
this.editor.setOptions(value)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// const langTools = ace.require("ace/ext/language_tools")
|
||||
|
||||
const editor = ace.edit(this.$refs.editor, {
|
||||
mode: `ace/mode/javascript`,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
...this.options,
|
||||
})
|
||||
|
||||
// Set the theme and show the editor only after it's been set to prevent FOUC.
|
||||
editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
|
||||
this.$nextTick()
|
||||
.then(() => {
|
||||
this.initialized = true
|
||||
})
|
||||
.catch(() => {
|
||||
// nextTIck shouldn't really ever throw but still
|
||||
this.initialized = true
|
||||
})
|
||||
})
|
||||
|
||||
editor.setFontSize(this.appFontSize)
|
||||
|
||||
const completer = {
|
||||
getCompletions: (
|
||||
editor,
|
||||
_session,
|
||||
{ row, column },
|
||||
_prefix,
|
||||
callback
|
||||
) => {
|
||||
if (this.completeMode === "pre") {
|
||||
getPreRequestScriptCompletions(editor.getValue(), row, column)
|
||||
.then((res) => {
|
||||
callback(
|
||||
null,
|
||||
res.completions.map((r, index, arr) => ({
|
||||
name: r.name,
|
||||
value: r.name,
|
||||
score: (arr.length - index) / arr.length,
|
||||
meta: r.type,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => callback(null, []))
|
||||
} else if (this.completeMode === "test") {
|
||||
getTestScriptCompletions(editor.getValue(), row, column)
|
||||
.then((res) => {
|
||||
callback(
|
||||
null,
|
||||
res.completions.map((r, index, arr) => ({
|
||||
name: r.name,
|
||||
value: r.name,
|
||||
score: (arr.length - index) / arr.length,
|
||||
meta: r.type,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => callback(null, []))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
editor.completers = [completer]
|
||||
|
||||
if (this.value) editor.setValue(this.value, 1)
|
||||
|
||||
this.editor = editor
|
||||
this.cacheValue = this.value
|
||||
|
||||
editor.on("change", () => {
|
||||
const content = editor.getValue()
|
||||
this.$emit("input", content)
|
||||
this.cacheValue = content
|
||||
this.provideLinting(content)
|
||||
})
|
||||
|
||||
this.provideLinting(this.value)
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.editor.destroy()
|
||||
},
|
||||
|
||||
methods: {
|
||||
defineTheme() {
|
||||
if (this.theme) {
|
||||
return this.theme
|
||||
}
|
||||
const strip = (str) =>
|
||||
str.replace(/#/g, "").replace(/ /g, "").replace(/"/g, "")
|
||||
return strip(
|
||||
window
|
||||
.getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--editor-theme")
|
||||
)
|
||||
},
|
||||
|
||||
provideLinting: debounce(function (code) {
|
||||
let results = []
|
||||
|
||||
const lintFunc =
|
||||
this.completeMode === "pre"
|
||||
? performPreRequestLinting
|
||||
: performTestLinting
|
||||
|
||||
lintFunc(code)
|
||||
.then((semanticLints) => {
|
||||
results = results.concat(
|
||||
semanticLints.map((lint) => ({
|
||||
row: lint.from.line,
|
||||
column: lint.from.ch,
|
||||
text: `[semantic] ${lint.message}`,
|
||||
type: "error",
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
const res = esprima.parseScript(code, { tolerant: true })
|
||||
if (res.errors && res.errors.length > 0) {
|
||||
results = results.concat(
|
||||
res.errors.map((err) => {
|
||||
const pos = this.editor.session
|
||||
.getDocument()
|
||||
.indexToPosition(err.index, 0)
|
||||
|
||||
return {
|
||||
row: pos.row,
|
||||
column: pos.column,
|
||||
text: `[syntax] ${err.description}`,
|
||||
type: "error",
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
const pos = this.editor.session
|
||||
.getDocument()
|
||||
.indexToPosition(e.index, 0)
|
||||
results = results.concat([
|
||||
{
|
||||
row: pos.row,
|
||||
column: pos.column,
|
||||
text: `[syntax] ${e.description}`,
|
||||
type: "error",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
this.editor.session.setAnnotations(results)
|
||||
})
|
||||
.catch(() => {
|
||||
try {
|
||||
const res = esprima.parseScript(code, { tolerant: true })
|
||||
if (res.errors && res.errors.length > 0) {
|
||||
results = results.concat(
|
||||
res.errors.map((err) => {
|
||||
const pos = this.editor.session
|
||||
.getDocument()
|
||||
.indexToPosition(err.index, 0)
|
||||
|
||||
return {
|
||||
row: pos.row,
|
||||
column: pos.column,
|
||||
text: `[syntax] ${err.description}`,
|
||||
type: "error",
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
const pos = this.editor.session
|
||||
.getDocument()
|
||||
.indexToPosition(e.index, 0)
|
||||
results = results.concat([
|
||||
{
|
||||
row: pos.row,
|
||||
column: pos.column,
|
||||
text: `[syntax] ${e.description}`,
|
||||
type: "error",
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
this.editor.session.setAnnotations(results)
|
||||
})
|
||||
}, 2000),
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.show-if-initialized {
|
||||
&.initialized {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
& > * {
|
||||
@apply transition-none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
packages/hoppscotch-app/components/smart/Link.js
Normal file
67
packages/hoppscotch-app/components/smart/Link.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* Vue 2 Functional Component: https://vuejs.org/v2/guide/render-function.html#Functional-Components */
|
||||
import { mergeData } from "vue-functional-data-merge"
|
||||
import getLinkTag, { ANCHOR_TAG, FRAMEWORK_LINK } from "~/assets/js/getLinkTag"
|
||||
|
||||
const SmartLink = {
|
||||
functional: true,
|
||||
props: {
|
||||
to: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
blank: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
// It's a convention to rename `createElement` to `h`
|
||||
render(h, context) {
|
||||
const tag = getLinkTag(context.props)
|
||||
|
||||
// Map our attributes correctly
|
||||
const attrs = {}
|
||||
let on = {}
|
||||
switch (tag) {
|
||||
case ANCHOR_TAG:
|
||||
// Map `to` prop to the correct attribute
|
||||
attrs.href = context.props.to
|
||||
|
||||
// Handle `blank` prop
|
||||
if (context.props.blank) {
|
||||
attrs.target = "_blank"
|
||||
attrs.rel = "noopener"
|
||||
}
|
||||
|
||||
// Transform native events to regular events for HTML anchor tag
|
||||
on = { ...context.data.nativeOn }
|
||||
delete context.data.nativeOn
|
||||
break
|
||||
|
||||
case FRAMEWORK_LINK:
|
||||
// Map `to` prop to the correct attribute
|
||||
attrs.to = context.props.to
|
||||
|
||||
// Handle `exact` prop
|
||||
if (context.props.exact) {
|
||||
attrs.exact = true
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Merge our new data with existing ones
|
||||
const data = mergeData(context.data, { attrs, on })
|
||||
|
||||
// Return a new virtual node
|
||||
return h(tag, data, context.children)
|
||||
},
|
||||
}
|
||||
|
||||
export default SmartLink
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="text-sm text-secondaryLight animate-pulse">
|
||||
<AppLogo class="h-8 w-8" />
|
||||
</div>
|
||||
</template>
|
||||
189
packages/hoppscotch-app/components/smart/Modal.vue
Normal file
189
packages/hoppscotch-app/components/smart/Modal.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<transition name="fade" appear @leave="onTransitionLeaveStart">
|
||||
<div
|
||||
ref="modal"
|
||||
class="inset-0 transition z-10 z-50 fixed hide-scrollbar overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-screen text-center items-end justify-center sm:block"
|
||||
>
|
||||
<transition name="fade" appear>
|
||||
<div
|
||||
class="bg-primaryDark opacity-90 inset-0 transition fixed"
|
||||
@touchstart="!dialog ? close() : null"
|
||||
@touchend="!dialog ? close() : null"
|
||||
@mouseup="!dialog ? close() : null"
|
||||
@mousedown="!dialog ? close() : null"
|
||||
></div>
|
||||
</transition>
|
||||
<span
|
||||
v-if="placement === 'center'"
|
||||
class="hidden sm:h-screen sm:inline-block sm:align-middle"
|
||||
aria-hidden="true"
|
||||
>​</span
|
||||
>
|
||||
<transition
|
||||
appear
|
||||
enter-active-class="transition"
|
||||
enter-class="translate-y-4 scale-95"
|
||||
enter-to-class="translate-y-0 scale-100"
|
||||
leave-active-class="transition"
|
||||
leave-class="translate-y-0 scale-100"
|
||||
leave-to-class="translate-y-4 scale-95"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
bg-primary
|
||||
shadow-lg
|
||||
text-left
|
||||
w-full
|
||||
transform
|
||||
transition-all
|
||||
inline-block
|
||||
align-bottom
|
||||
overflow-hidden
|
||||
sm:max-w-md sm:align-middle
|
||||
md:rounded-lg
|
||||
"
|
||||
:class="[
|
||||
{ 'mt-24 md:mb-8': placement === 'top' },
|
||||
{ 'p-4': !fullWidth },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex mb-4 pl-2 items-center justify-between"
|
||||
>
|
||||
<h3 class="heading">{{ title }}</h3>
|
||||
<span class="flex">
|
||||
<slot name="actions"></slot>
|
||||
<ButtonSecondary
|
||||
v-if="dimissible"
|
||||
class="rounded"
|
||||
svg="x"
|
||||
@click.native="close"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col max-h-md overflow-y-auto hide-scrollbar"
|
||||
:class="{ 'py-2': !fullWidth }"
|
||||
>
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasFooterSlot"
|
||||
class="flex flex-1 mt-4 p-2 items-center justify-between"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
import { useKeybindingDisabler } from "~/helpers/keybindings"
|
||||
|
||||
const PORTAL_DOM_ID = "hoppscotch-modal-portal"
|
||||
|
||||
// Why ?
|
||||
const stack = (() => {
|
||||
const stack: number[] = []
|
||||
return {
|
||||
push: stack.push.bind(stack),
|
||||
pop: stack.pop.bind(stack),
|
||||
peek: () => (stack.length === 0 ? undefined : stack[stack.length - 1]),
|
||||
}
|
||||
})()
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
dialog: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
dimissible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: "top",
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler()
|
||||
|
||||
return {
|
||||
disableKeybindings,
|
||||
enableKeybindings,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
stackId: Math.random(),
|
||||
// when doesn't fire on unmount, we should manually remove the modal from DOM
|
||||
// (for example, when the parent component of this modal gets destroyed)
|
||||
shouldCleanupDomOnUnmount: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasFooterSlot(): boolean {
|
||||
return !!this.$slots.footer
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const $portal = this.$getPortal()
|
||||
$portal.appendChild(this.$refs.modal as any)
|
||||
stack.push(this.stackId)
|
||||
document.addEventListener("keydown", this.onKeyDown)
|
||||
this.disableKeybindings()
|
||||
},
|
||||
beforeDestroy() {
|
||||
const $modal = this.$refs.modal
|
||||
if (this.shouldCleanupDomOnUnmount && $modal) {
|
||||
this.$getPortal().removeChild($modal as any)
|
||||
}
|
||||
stack.pop()
|
||||
document.removeEventListener("keydown", this.onKeyDown)
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$emit("close")
|
||||
this.enableKeybindings()
|
||||
},
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && this.stackId === stack.peek()) {
|
||||
e.preventDefault()
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
onTransitionLeaveStart() {
|
||||
this.close()
|
||||
this.shouldCleanupDomOnUnmount = false
|
||||
},
|
||||
$getPortal() {
|
||||
let $el = document.querySelector("#" + PORTAL_DOM_ID)
|
||||
if ($el) {
|
||||
return $el
|
||||
}
|
||||
$el = document.createElement("DIV")
|
||||
$el.id = PORTAL_DOM_ID
|
||||
document.body.appendChild($el)
|
||||
return $el
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
57
packages/hoppscotch-app/components/smart/ProgressRing.vue
Normal file
57
packages/hoppscotch-app/components/smart/ProgressRing.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<svg :height="radius * 2" :width="radius * 2">
|
||||
<circle
|
||||
:stroke-width="stroke"
|
||||
class="stroke-green-500"
|
||||
fill="transparent"
|
||||
:r="normalizedRadius"
|
||||
:cx="radius"
|
||||
:cy="radius"
|
||||
/>
|
||||
<circle
|
||||
:stroke-width="stroke"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
:r="normalizedRadius"
|
||||
:cx="radius"
|
||||
:cy="radius"
|
||||
:style="{ strokeDashoffset: strokeDashoffset }"
|
||||
:stroke-dasharray="circumference + ' ' + circumference"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
radius: {
|
||||
type: Number,
|
||||
default: 12,
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
stroke: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const normalizedRadius = this.radius - this.stroke * 2
|
||||
const circumference = normalizedRadius * 2 * Math.PI
|
||||
|
||||
return {
|
||||
normalizedRadius,
|
||||
circumference,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
strokeDashoffset() {
|
||||
return this.circumference - (this.progress / 100) * this.circumference
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
22
packages/hoppscotch-app/components/smart/Spinner.vue
Normal file
22
packages/hoppscotch-app/components/smart/Spinner.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-4 animate-spin w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
32
packages/hoppscotch-app/components/smart/Tab.vue
Normal file
32
packages/hoppscotch-app/components/smart/Tab.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div v-show="active">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
label: { type: String, default: null },
|
||||
info: { type: String, default: null },
|
||||
icon: { type: String, default: null },
|
||||
id: { type: String, default: null, required: true },
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
active: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.active = this.selected
|
||||
},
|
||||
})
|
||||
</script>
|
||||
145
packages/hoppscotch-app/components/smart/Tabs.vue
Normal file
145
packages/hoppscotch-app/components/smart/Tabs.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-nowrap flex-1">
|
||||
<div class="tabs hide-scrollbar relative" :class="styles">
|
||||
<div class="flex flex-1">
|
||||
<div class="flex flex-1 justify-between">
|
||||
<div class="flex">
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="`tab-${index}`"
|
||||
class="tab"
|
||||
:class="{ active: tab.active }"
|
||||
tabindex="0"
|
||||
@keyup.enter="selectTab(tab)"
|
||||
@click="selectTab(tab)"
|
||||
>
|
||||
<i v-if="tab.icon" class="material-icons">
|
||||
{{ tab.icon }}
|
||||
</i>
|
||||
<span v-if="tab.label">{{ tab.label }}</span>
|
||||
<span v-if="tab.info" class="tab-info">
|
||||
{{ tab.info }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
styles: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
tabs: [],
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.tabs = this.$children
|
||||
},
|
||||
|
||||
methods: {
|
||||
selectTab({ id }) {
|
||||
this.tabs.forEach((tab) => {
|
||||
tab.active = tab.id === id
|
||||
})
|
||||
this.$emit("tab-changed", id)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tabs {
|
||||
@apply flex;
|
||||
@apply whitespace-nowrap;
|
||||
@apply overflow-auto;
|
||||
|
||||
// &::after {
|
||||
// @apply absolute;
|
||||
// @apply inset-x-0;
|
||||
// @apply bottom-0;
|
||||
// @apply bg-dividerLight;
|
||||
// @apply z-1;
|
||||
// @apply h-0.5;
|
||||
|
||||
// content: "";
|
||||
// }
|
||||
|
||||
.tab {
|
||||
@apply relative;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply px-4 py-2;
|
||||
@apply text-secondary;
|
||||
@apply font-semibold;
|
||||
@apply cursor-pointer;
|
||||
@apply hover:text-secondaryDark;
|
||||
@apply focus:outline-none;
|
||||
@apply focus-visible:text-secondaryDark;
|
||||
|
||||
.tab-info {
|
||||
@apply inline-flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply w-5;
|
||||
@apply h-4;
|
||||
@apply ml-2;
|
||||
@apply text-8px;
|
||||
@apply border border-divider;
|
||||
|
||||
@apply rounded;
|
||||
@apply text-secondaryLight;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply absolute;
|
||||
@apply left-4;
|
||||
@apply right-4;
|
||||
@apply bottom-0;
|
||||
@apply bg-transparent;
|
||||
@apply z-2;
|
||||
@apply h-0.5;
|
||||
|
||||
content: "";
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
@apply mr-4;
|
||||
}
|
||||
|
||||
&:focus::after {
|
||||
@apply bg-divider;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply text-secondaryDark;
|
||||
|
||||
.tab-info {
|
||||
@apply text-secondary;
|
||||
@apply border-dividerDark;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bg-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
packages/hoppscotch-app/components/smart/Toggle.vue
Normal file
84
packages/hoppscotch-app/components/smart/Toggle.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer flex-nowrap inline-flex items-center justify-center"
|
||||
@click="$emit('change')"
|
||||
>
|
||||
<label ref="toggle" class="toggle" :class="{ on: on }">
|
||||
<span class="handle"></span>
|
||||
</label>
|
||||
<label class="cursor-pointer pl-0 align-middle">
|
||||
<slot></slot>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from "@nuxtjs/composition-api"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
on: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$useBorder: true;
|
||||
$borderColor: var(--divider-color);
|
||||
$activeColor: var(--divider-color);
|
||||
$inactiveColor: var(--divider-color);
|
||||
$inactiveHandleColor: var(--secondary-light-color);
|
||||
$activeHandleColor: var(--accent-color);
|
||||
$width: 1.6rem;
|
||||
$height: 0.6rem;
|
||||
$indicatorHeight: 0.4rem;
|
||||
$indicatorWidth: 0.4rem;
|
||||
$handleSpacing: 0.1rem;
|
||||
$transition: all 0.2s ease-in-out;
|
||||
|
||||
.toggle {
|
||||
@apply relative;
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-center;
|
||||
@apply rounded-full;
|
||||
@apply p-0;
|
||||
@apply mr-4;
|
||||
@apply cursor-pointer;
|
||||
@apply flex-shrink-0;
|
||||
|
||||
width: $width;
|
||||
height: $height;
|
||||
border: if($useBorder, 2px solid $borderColor, none);
|
||||
background-color: if($useBorder, transparent, $inactiveColor);
|
||||
box-sizing: initial;
|
||||
|
||||
.handle {
|
||||
@apply absolute;
|
||||
@apply flex;
|
||||
@apply flex-shrink-0;
|
||||
@apply inset-0;
|
||||
@apply rounded-full;
|
||||
@apply pointer-events-none;
|
||||
|
||||
transition: $transition;
|
||||
margin: $handleSpacing;
|
||||
background-color: $inactiveHandleColor;
|
||||
width: $indicatorWidth;
|
||||
height: $indicatorHeight;
|
||||
}
|
||||
|
||||
&.on {
|
||||
// background-color: $activeColor;
|
||||
border-color: $activeColor;
|
||||
|
||||
.handle {
|
||||
background-color: $activeHandleColor;
|
||||
left: #{$width - $height};
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import autocomplete from "../AutoComplete"
|
||||
|
||||
const props = {
|
||||
placeholder: "",
|
||||
value: "",
|
||||
spellcheck: false,
|
||||
source: ["app", "apple", "appliance", "brian", "bob", "alice"],
|
||||
}
|
||||
|
||||
// ["pp", "pple", "ppliance", "lice"]
|
||||
const suggestionStr = props.source
|
||||
.filter((str) => str.startsWith("a"))
|
||||
.map((str) => str.slice(1))
|
||||
|
||||
const factory = (props) =>
|
||||
mount(autocomplete, {
|
||||
propsData: props,
|
||||
})
|
||||
|
||||
describe("autocomplete", () => {
|
||||
test("mounts properly", () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("emits input event on text update [v-model compat]", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("testval")
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted("input")).toBeTruthy()
|
||||
expect(wrapper.emitted("input").length).toEqual(1)
|
||||
})
|
||||
|
||||
test("shows matching suggestions", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const suggestions = wrapper.findAll("li").wrappers.map((el) => el.text())
|
||||
|
||||
suggestionStr.forEach((str) => expect(suggestions).toContain(str))
|
||||
})
|
||||
|
||||
test("doesnt show non-matching suggestions", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const suggestions = wrapper.findAll("li").wrappers.map((el) => el.text())
|
||||
|
||||
suggestionStr.forEach((str) => expect(suggestions).not.toContain(str))
|
||||
})
|
||||
|
||||
test("updates suggestions on input", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const suggestions = wrapper.findAll("li").wrappers.map((el) => el.text())
|
||||
|
||||
suggestionStr.forEach((str) => expect(suggestions).not.toContain(str))
|
||||
})
|
||||
|
||||
test("applies suggestion on clicking", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const selectedSuggestion = wrapper.findAll("li").at(0)
|
||||
const selectedText = selectedSuggestion.text()
|
||||
|
||||
await selectedSuggestion.trigger("click")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toEqual(`b${selectedText}`)
|
||||
})
|
||||
|
||||
test("hide selection on pressing ESC", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keyup", { code: "Escape" })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find("ul").exists()).toEqual(false)
|
||||
})
|
||||
|
||||
test("pressing up when nothing is selected selects the first in the list", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowUp",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(0).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing down arrow when nothing is selected selects the first in the list", async () => {
|
||||
const wrapper = factory(props)
|
||||
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(0).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing down arrow moves down the selection list", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(1).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing up arrow moves up the selection list", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowUp",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(0).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing down arrow at the end of the list doesn't update the selection", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(1).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(1).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing up arrow at the top of the list doesn't update the selection", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("b")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowUp",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(0).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowUp",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findAll("li").at(0).element.classList.contains("active")
|
||||
).toEqual(true)
|
||||
})
|
||||
|
||||
test("pressing tab performs the current completion", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const selectedSuggestion = wrapper.find("li.active").text()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "Tab",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toEqual(`a${selectedSuggestion}`)
|
||||
})
|
||||
|
||||
test("pressing tab when nothing is selected selects the first suggestion", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const firstSuggestionText = wrapper.findAll("li").at(0).text()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "Tab",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toEqual(`a${firstSuggestionText}`)
|
||||
})
|
||||
|
||||
test("pressing any non-special key doesn't do anything", async () => {
|
||||
const wrapper = factory(props)
|
||||
const input = wrapper.find("input")
|
||||
|
||||
await input.setValue("a")
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "ArrowDown",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const selectedSuggestion = wrapper.find("li.active").text()
|
||||
|
||||
await input.trigger("keydown", {
|
||||
code: "Tab",
|
||||
})
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toEqual(`a${selectedSuggestion}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import tab from "../Tab"
|
||||
|
||||
const factory = (props, data) => {
|
||||
if (data) {
|
||||
return mount(tab, {
|
||||
propsData: props,
|
||||
data: () => data,
|
||||
slots: {
|
||||
default: '<div id="testdiv"></div>',
|
||||
},
|
||||
})
|
||||
} else {
|
||||
return mount(tab, {
|
||||
propsData: props,
|
||||
slots: {
|
||||
default: '<div id="testdiv"></div>',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("tab", () => {
|
||||
test("mounts properly when needed props are passed in", () => {
|
||||
const wrapper = factory({
|
||||
label: "TestLabel",
|
||||
icon: "TestIcon",
|
||||
id: "testid",
|
||||
selected: true,
|
||||
})
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("mounts properly when selected prop is not passed", () => {
|
||||
const wrapper = factory({
|
||||
label: "TestLabel",
|
||||
icon: "TestIcon",
|
||||
id: "testid",
|
||||
})
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("if 'selected' prop is not passed, it is set to false by default", () => {
|
||||
const wrapper = factory({
|
||||
label: "TestLabel",
|
||||
icon: "TestIcon",
|
||||
id: "testid",
|
||||
})
|
||||
|
||||
expect(wrapper.props("selected")).toEqual(false)
|
||||
})
|
||||
|
||||
test("if set active, the slot is shown", () => {
|
||||
const wrapper = factory(
|
||||
{
|
||||
label: "TestLabel",
|
||||
icon: "TestIcon",
|
||||
id: "testid",
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
active: true,
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.find("#testdiv").isVisible()).toEqual(true)
|
||||
})
|
||||
|
||||
test("if not set active, the slot is not rendered", () => {
|
||||
const wrapper = factory(
|
||||
{
|
||||
label: "TestLabel",
|
||||
icon: "TestIcon",
|
||||
id: "testid",
|
||||
},
|
||||
{
|
||||
active: false,
|
||||
}
|
||||
)
|
||||
|
||||
expect(wrapper.find("#testdiv").isVisible()).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import tabs from "../Tabs"
|
||||
import tab from "../Tab"
|
||||
|
||||
const factory = () =>
|
||||
mount(tabs, {
|
||||
slots: {
|
||||
default: [
|
||||
`<Tab id="tab1" href="#" :label="'tab 1'" :icon="'testicon1'" :selected=true><div id="tab1render">tab1</div></Tab>`,
|
||||
`<Tab id="tab2" href="#" :label="'tab 2'" :icon="'testicon2'"><div id="tab2render">tab1</div></Tab>`,
|
||||
`<Tab id="tab3" href="#" :label="'tab 3'" :icon="'testicon3'"><div id="tab3render">tab1</div></Tab>`,
|
||||
],
|
||||
},
|
||||
stubs: {
|
||||
Tab: tab,
|
||||
},
|
||||
})
|
||||
|
||||
describe("tabs", () => {
|
||||
test("mounts properly", async () => {
|
||||
const wrapper = factory()
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("tab labels shown", async () => {
|
||||
const wrapper = factory()
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const labels = wrapper.findAll("button span").wrappers.map((w) => w.text())
|
||||
expect(labels).toEqual(["tab 1", "tab 2", "tab 3"])
|
||||
})
|
||||
|
||||
test("tab icons are shown", async () => {
|
||||
const wrapper = factory()
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const labels = wrapper.findAll("li a i").wrappers.map((w) => w.text())
|
||||
expect(labels).toEqual(["testicon1", "testicon2", "testicon3"])
|
||||
})
|
||||
|
||||
test("clicking on tab labels switches the selected page", async () => {
|
||||
const wrapper = factory()
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
wrapper.vm.selectTab({ id: "tab2" })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.vm.$data.tabs[1].$data.active).toEqual(true)
|
||||
})
|
||||
|
||||
test("switched tab page is rendered and the other page is not rendered", async () => {
|
||||
const wrapper = factory()
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
wrapper.vm.selectTab({ id: "tab2" })
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.vm.$data.tabs[0].$data.active).toEqual(false)
|
||||
expect(wrapper.vm.$data.tabs[1].$data.active).toEqual(true)
|
||||
expect(wrapper.vm.$data.tabs[2].$data.active).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import pwToggle from "../Toggle"
|
||||
|
||||
const factory = (props, slot) =>
|
||||
mount(pwToggle, {
|
||||
propsData: props,
|
||||
slots: {
|
||||
default: slot,
|
||||
},
|
||||
})
|
||||
|
||||
describe("pwToggle", () => {
|
||||
test("mounts properly", () => {
|
||||
const wrapper = factory({ on: true }, "test")
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("mounts even without the on prop", () => {
|
||||
const wrapper = factory({}, "test")
|
||||
|
||||
expect(wrapper).toBeTruthy()
|
||||
})
|
||||
|
||||
test("state is set correctly through the prop", () => {
|
||||
const wrapper1 = factory({ on: true }, "test")
|
||||
expect(wrapper1.vm.$refs.toggle.classList.contains("on")).toEqual(true)
|
||||
|
||||
const wrapper2 = factory({ on: false }, "test")
|
||||
expect(wrapper2.vm.$refs.toggle.classList.contains("on")).toEqual(false)
|
||||
})
|
||||
|
||||
test("caption label is rendered", () => {
|
||||
const wrapper = factory({ on: true }, "<span id='testcaption'></span>")
|
||||
|
||||
expect(wrapper.find("#testcaption").exists()).toEqual(true)
|
||||
})
|
||||
|
||||
// test("clicking the button toggles the state", async () => {
|
||||
// const wrapper = factory({ on: true }, "test")
|
||||
|
||||
// wrapper.vm.toggle()
|
||||
// await wrapper.vm.$nextTick()
|
||||
|
||||
// expect(wrapper.vm.$refs.toggle.classList.contains("on")).toEqual(false)
|
||||
|
||||
// wrapper.vm.toggle()
|
||||
// await wrapper.vm.$nextTick()
|
||||
|
||||
// expect(wrapper.vm.$refs.toggle.classList.contains("on")).toEqual(true)
|
||||
// })
|
||||
})
|
||||
Reference in New Issue
Block a user