feat: add search in environment selector and sidebar (#4872)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Nivedin 2025-03-24 22:18:19 +05:30 committed by GitHub
parent 1a2b9516c9
commit e43d0f1b70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 288 additions and 71 deletions

View file

@ -68,7 +68,6 @@
"turn_on": "Turn on",
"undo": "Undo",
"yes": "Yes",
"confirm": "Confirm",
"verify": "Verify",
"enable": "Enable",
"disable": "Disable"
@ -367,6 +366,7 @@
"protocols": "Protocols are empty",
"request_variables": "This request does not have any request variables",
"schema": "Connect to a GraphQL endpoint to view schema",
"search_environment": "No matching environment found for",
"secret_environments": "Secrets are not synced to Hoppscotch",
"shared_requests": "Shared requests are empty",
"shared_requests_logout": "Login to view your shared requests or create a new one",

View file

@ -26,12 +26,20 @@
<div
ref="envSelectorActions"
role="menu"
class="flex flex-col focus:outline-none"
class="flex flex-col space-y-2 focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartEnvInput
v-model="filterText"
:placeholder="`${t('action.search')}`"
:context-menu-enabled="false"
class="border border-dividerDark focus:border-primaryDark rounded"
:readonly="isFilterInputDisabled"
/>
<HoppSmartItem
v-if="!isScopeSelector"
class="my-2"
:label="`${t('environment.no_environment')}`"
:info-icon="
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
@ -65,7 +73,7 @@
/>
<HoppSmartTabs
v-model="selectedEnvTab"
:styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary ${
:styles="`sticky overflow-x-auto mb-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary ${
!isTeamSelected || workspace.type === 'personal'
? 'bg-primaryLight'
: ''
@ -77,10 +85,7 @@
:label="`${t('environment.my_environments')}`"
>
<HoppSmartItem
v-for="{
env,
index,
} in alphabeticallySortedPersonalEnvironments"
v-for="{ env, index } in filteredAndAlphabetizedPersonalEnvs"
:key="`gen-${index}`"
:icon="IconLayers"
:label="env.name"
@ -97,12 +102,30 @@
"
/>
<HoppSmartPlaceholder
v-if="alphabeticallySortedPersonalEnvironments.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
/>
v-if="filteredAndAlphabetizedPersonalEnvs.length === 0"
class="break-words"
:src="
filterText
? undefined
: `/images/states/${colorMode.value}/blockchain.svg`
"
:alt="
filterText
? `${t('empty.search_environment')}`
: t('empty.environments')
"
:text="
filterText
? `${t('empty.search_environment')} '${filterText}'`
: t('empty.environments')
"
>
<template v-if="filterText" #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
</HoppSmartTab>
<HoppSmartTab
:id="'team-environments'"
:label="`${t('environment.team_environments')}`"
@ -119,7 +142,7 @@
</div>
<div v-if="isTeamSelected" class="flex flex-col">
<HoppSmartItem
v-for="{ env, index } in alphabeticallySortedTeamEnvironments"
v-for="{ env, index } in filteredAndAlphabetizedTeamEnvs"
:key="`gen-team-${index}`"
:icon="IconLayers"
:label="env.environment.name"
@ -136,11 +159,28 @@
"
/>
<HoppSmartPlaceholder
v-if="alphabeticallySortedTeamEnvironments.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
/>
v-if="filteredAndAlphabetizedTeamEnvs.length === 0"
class="break-words"
:src="
filteredAndAlphabetizedTeamEnvs.length === 0 && !filterText
? `/images/states/${colorMode.value}/blockchain.svg`
: undefined
"
:alt="
filterText
? `${t('empty.search_environment')}`
: t('empty.environments')
"
:text="
filterText
? `${t('empty.search_environment')} '${filterText}'`
: t('empty.environments')
"
>
<template v-if="filterText" #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder>
</div>
<div
v-if="!teamListLoading && teamAdapterError"
@ -356,6 +396,8 @@ const colorMode = useColorMode()
type EnvironmentType = "my-environments" | "team-environments"
const filterText = ref("")
const myEnvironments = useReadonlyStream(environments$, [])
const workspaceService = useService(WorkspaceService)
@ -398,14 +440,44 @@ const teamEnvironmentList = useReadonlyStream(
[]
)
// Sort environments alphabetically by default
const alphabeticallySortedPersonalEnvironments = computed(() =>
sortPersonalEnvironmentsAlphabetically(myEnvironments.value, "asc")
)
// Sort environments alphabetically by default and filter based on search
const filteredAndAlphabetizedPersonalEnvs = computed(() => {
const envs = sortPersonalEnvironmentsAlphabetically(
myEnvironments.value,
"asc"
)
const alphabeticallySortedTeamEnvironments = computed(() =>
sortTeamEnvironmentsAlphabetically(teamEnvironmentList.value, "asc")
)
if (selectedEnvTab.value !== "my-environments" || !filterText.value)
return envs
// Ensure specifying whitespace characters alone result in the empty state for no search results
const trimmedFilterText = filterText.value.trim().toLowerCase()
return envs.filter(({ env }) =>
trimmedFilterText
? env.name.toLowerCase().includes(trimmedFilterText)
: false
)
})
const filteredAndAlphabetizedTeamEnvs = computed(() => {
const envs = sortTeamEnvironmentsAlphabetically(
teamEnvironmentList.value,
"asc"
)
if (selectedEnvTab.value !== "team-environments" || !filterText.value)
return envs
// Ensure specifying whitespace characters alone result in the empty state for no search results
const trimmedFilterText = filterText.value.trim().toLowerCase()
return envs.filter(({ env }) =>
trimmedFilterText
? env.environment.name.toLowerCase().includes(trimmedFilterText)
: false
)
})
const handleEnvironmentChange = (
index: number,
@ -603,6 +675,14 @@ const editGlobalEnv = () => {
invokeAction("modals.global.environment.update", {})
}
// Filter input disabled if no environments are available
const isFilterInputDisabled = computed(() => {
if (selectedEnvTab.value === "my-environments") {
return myEnvironments.value.length === 0
}
return teamEnvironmentList.value.length === 0
})
const editEnv = () => {
if (selectedEnv.value.type === "MY_ENV" && selectedEnv.value.name) {
invokeAction("modals.my.environment.edit", {

View file

@ -1,5 +1,13 @@
<template>
<div>
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8 border-b border-dividerLight"
:placeholder="t('action.search')"
:disabled="!environments.length"
/>
<div
class="sticky top-upperPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
>
@ -25,8 +33,9 @@
/>
</div>
</div>
<EnvironmentsMyEnvironment
v-for="{ env, index } in alphabeticallySortedPersonalEnvironments"
v-for="{ env, index } in filteredAndAlphabetizedPersonalEnvs"
:key="`environment-${index}`"
:environment-index="index"
:environment="env"
@ -35,12 +44,28 @@
@select-environment="selectEnvironment(index, env)"
/>
<HoppSmartPlaceholder
v-if="!alphabeticallySortedPersonalEnvironments.length"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
v-if="filteredAndAlphabetizedPersonalEnvs.length === 0"
:alt="
filterText
? `${t('empty.search_environment')}`
: t('empty.environments')
"
:text="
filterText
? `${t('empty.search_environment')} '${filterText}'`
: t('empty.environments')
"
:src="
filterText
? undefined
: `/images/states/${colorMode.value}/blockchain.svg`
"
>
<template #body>
<template v-if="filterText" #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
<template v-else #body>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("environment.import_or_create") }}
@ -106,10 +131,27 @@ const emit = defineEmits<{
const environments = useReadonlyStream(environments$, [])
// Sort environments alphabetically by default
const alphabeticallySortedPersonalEnvironments = computed(() =>
sortPersonalEnvironmentsAlphabetically(environments.value, "asc")
)
const filterText = ref("")
// Sort environments alphabetically by default and filter by search text
const filteredAndAlphabetizedPersonalEnvs = computed(() => {
const envs = sortPersonalEnvironmentsAlphabetically(environments.value, "asc")
const rawFilter = filterText.value
// Ensure specifying whitespace characters alone result in the empty state for no search results
const trimmedFilter = rawFilter.trim().toLowerCase()
// Whitespace-only input results in an empty state
if (rawFilter && !trimmedFilter) return []
// No search text Show all environments
if (!trimmedFilter) return envs
// Filter environments based on search text
return envs.filter(({ env }) =>
env.name.toLowerCase().includes(trimmedFilter)
)
})
const showModalImportExport = ref(false)
const showModalDetails = ref(false)
@ -166,7 +208,7 @@ defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const env = alphabeticallySortedPersonalEnvironments.value.find(
const env = filteredAndAlphabetizedPersonalEnvs.value.find(
({ env }) => env.name === envName
)
if (envName !== "Global" && env) {

View file

@ -1,5 +1,13 @@
<template>
<div>
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8 border-b border-dividerLight"
:placeholder="t('action.search')"
:disabled="loading || !teamEnvironments.length"
/>
<div
class="sticky top-upperPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
>
@ -43,17 +51,40 @@
/>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else-if="adapterError" class="flex flex-col items-center py-4">
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ t(getEnvActionErrorMessage(adapterError)) }}
</div>
<HoppSmartPlaceholder
v-if="
!loading &&
!alphabeticallySortedTeamEnvironments.length &&
!adapterError
v-else-if="filteredAndAlphabetizedTeamEnvs.length === 0"
:alt="
filterText
? `${t('empty.search_environment')}`
: t('empty.environments')
"
:text="
filterText
? `${t('empty.search_environment')} '${filterText}'`
: t('empty.environments')
"
:src="
filterText
? undefined
: `/images/states/${colorMode.value}/blockchain.svg`
"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<template #body>
<template v-if="filterText" #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
<template v-else #body>
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("environment.import_or_create") }}
@ -81,10 +112,11 @@
</div>
</template>
</HoppSmartPlaceholder>
<div v-else-if="!loading">
<div v-else>
<EnvironmentsTeamsEnvironment
v-for="{ env, index } in JSON.parse(
JSON.stringify(alphabeticallySortedTeamEnvironments)
JSON.stringify(filteredAndAlphabetizedTeamEnvs)
)"
:key="`environment-${index}`"
:environment="env"
@ -97,17 +129,7 @@
"
/>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ t(getEnvActionErrorMessage(adapterError)) }}
</div>
<EnvironmentsTeamsDetails
:show="showModalDetails"
:action="action"
@ -120,9 +142,7 @@
/>
<EnvironmentsImportExport
v-if="showModalImportExport"
:team-environments="
alphabeticallySortedTeamEnvironments.map(({ env }) => env)
"
:team-environments="filteredAndAlphabetizedTeamEnvs.map(({ env }) => env)"
:team-id="team?.teamID"
environment-type="TEAM_ENV"
@hide-modal="displayModalImportExport(false)"
@ -168,11 +188,27 @@ const emit = defineEmits<{
(e: "select-environment", data: HandleEnvChangeProp): void
}>()
// Sort environments alphabetically by default
const filterText = ref("")
const alphabeticallySortedTeamEnvironments = computed(() =>
sortTeamEnvironmentsAlphabetically(props.teamEnvironments, "asc")
)
// Sort environments alphabetically by default and filter by search text
const filteredAndAlphabetizedTeamEnvs = computed(() => {
const envs = sortTeamEnvironmentsAlphabetically(props.teamEnvironments, "asc")
const rawFilter = filterText.value
// Ensure specifying whitespace characters alone result in the empty state for no search results
const trimmedFilter = rawFilter.trim().toLowerCase()
// Whitespace-only input results in an empty state
if (rawFilter && !trimmedFilter) return []
// No search text Show all environments
if (!trimmedFilter) return envs
// Filter environments based on search text
return envs.filter(({ env }) =>
env.environment.name.toLowerCase().includes(trimmedFilter)
)
})
const showModalImportExport = ref(false)
const showModalDetails = ref(false)
@ -241,7 +277,7 @@ defineActionHandler(
"modals.team.environment.edit",
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const teamEnvToEdit = alphabeticallySortedTeamEnvironments.value.find(
const teamEnvToEdit = filteredAndAlphabetizedTeamEnvs.value.find(
({ env }) => env.environment.name === envName
)
if (teamEnvToEdit) {

View file

@ -73,7 +73,12 @@ import {
keymap,
tooltips,
} from "@codemirror/view"
import { EditorSelection, EditorState, Extension } from "@codemirror/state"
import {
Compartment,
EditorSelection,
EditorState,
Extension,
} from "@codemirror/state"
import { clone } from "lodash-es"
import { history, historyKeymap } from "@codemirror/commands"
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
@ -156,6 +161,9 @@ const isSecret = ref(props.secret)
const secretText = ref(props.modelValue)
// Compartment to store the readOnly state of the editor
const readOnly = new Compartment()
watch(
() => secretText.value,
(newVal) => {
@ -478,16 +486,14 @@ const initView = (el: any) => {
}
const getExtensions = (readonly: boolean): Extension => {
const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
const readOnlyConfigs = [
EditorView.updateListener.of((update) => {
if (readonly) {
update.view.contentDOM.inputMode = "none"
update.view.contentDOM.contentEditable = "false"
}
}),
EditorState.changeFilter.of(() => !readonly),
inputTheme,
readonly
? EditorView.theme({
".cm-content": {
@ -498,6 +504,13 @@ const getExtensions = (readonly: boolean): Extension => {
},
})
: EditorView.theme({}),
]
const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
inputTheme,
readOnly.of(readOnlyConfigs),
tooltips({
parent: document.body,
position: "absolute",
@ -529,7 +542,8 @@ const getExtensions = (readonly: boolean): Extension => {
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (readonly) return
// since the readonly prop is reactive, we need to check if it has changed
if (props.readonly) return
if (update.docChanged) {
const prevValue = clone(cachedValue.value)
@ -610,6 +624,51 @@ watch(editor, () => {
view.value = undefined
}
})
/**
* Watch for changes in the readonly prop
* and update the editor state accordingly
*/
watch(
() => props.readonly,
(isReadOnly) => {
if (isReadOnly) {
view.value?.dispatch({
effects: readOnly.reconfigure([
EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color)",
color: "var(--secondary-dark-color)",
backgroundColor: "var(--divider-color)",
opacity: 0.25,
},
}),
EditorState.changeFilter.of(() => false),
EditorState.readOnly.of(true),
]),
})
//change input mode and contenteditable to false to prevent keyboard input
view.value?.contentDOM.setAttribute("inputmode", "none")
view.value?.contentDOM.setAttribute("contenteditable", "false")
} else {
view.value?.dispatch({
effects: readOnly.reconfigure([
EditorView.theme({}),
EditorState.changeFilter.of(() => true),
EditorState.readOnly.of(false),
]),
})
//change input mode and contenteditable to true to allow keyboard input
view.value?.contentDOM.setAttribute("inputmode", "text")
view.value?.contentDOM.setAttribute("contenteditable", "true")
}
},
{
immediate: true,
}
)
</script>
<style lang="scss" scoped>