feat: added inline environment variable edit button (#2813)

* refactor: changes v-if render to v-show on Environments tabs

* feat: adds selectText prop to EnvInput

* feat: adds editing variable name to env Details modal

* feat: adds actions to invoke edit env modals

* feat: adds edit action to tooltip env

* refactor: adds destructuring assignment on action handlers for edit env modals

* refactor: fix comment on environment modals action

* chore: minor ui improvements

* refactor: change text selecion prop on EnvInput to something more meaningful

* refactor: removes comment on HoppEnvironment extension

* refactor: renames isTextSelected EnvInput prop to selectTextOnMount

* refactor: remove type definition of automatic inferrable variables

* refactor: edit environment call to only allow accepted types

* feat: introduce type safe action arguments

* fix: revert v-show to v-if

* chore: minor ui improvements

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Francisco Emanuel de Sales Pereira
2022-11-02 09:25:22 -03:00
committed by GitHub
parent 696c612489
commit 0069f51ea4
10 changed files with 176 additions and 30 deletions

View File

@@ -132,6 +132,7 @@ a {
.cm-tooltip {
.tippy-box {
@apply shadow-none;
@apply fixed;
@apply inline-flex;
@apply -mt-6;
@@ -166,10 +167,9 @@ a {
}
.env-icon {
@apply transition;
@apply inline-flex;
@apply items-center;
@apply mr-1;
@apply text-accentDark;
}
}

View File

@@ -155,9 +155,9 @@
@update-selected-team="updateSelectedTeam"
/>
</div>
<EnvironmentsMy v-if="environmentType.type === 'my-environments'" />
<EnvironmentsMy v-show="environmentType.type === 'my-environments'" />
<EnvironmentsTeams
v-else
v-show="environmentType.type === 'team-environments'"
:team="environmentType.selectedTeam"
:team-environments="teamEnvironmentList"
:loading="loading"

View File

@@ -62,6 +62,7 @@
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
@@ -163,6 +164,7 @@ const props = withDefaults(
show: boolean
action: "edit" | "new"
editingEnvironmentIndex: number | "Global" | null
editingVariableName: string | null
envVars?: () => Environment["variables"]
}>(),
{

View File

@@ -64,6 +64,7 @@
:show="showModalDetails"
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsImportExport
@@ -83,6 +84,8 @@ import { useI18n } from "~/composables/i18n"
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { Environment } from "@hoppscotch/data"
import { defineActionHandler } from "~/helpers/actions"
const t = useI18n()
const colorMode = useColorMode()
@@ -100,6 +103,7 @@ const showModalImportExport = ref(false)
const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<number | "Global" | null>(null)
const editingVariableName = ref("")
const displayModalAdd = (shouldDisplay: boolean) => {
action.value = "new"
@@ -122,4 +126,17 @@ const editEnvironment = (environmentIndex: number | "Global") => {
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
}
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {
editingVariableName.value = variableName
const envIndex: number = environments.value.findIndex(
(environment: Environment) => {
return environment.name === envName
}
)
editEnvironment(envIndex >= 0 ? envIndex : "Global")
}
)
</script>

View File

@@ -65,6 +65,7 @@
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
@@ -173,6 +174,7 @@ const props = withDefaults(
action: "edit" | "new"
editingEnvironment: TeamEnvironment | null
editingTeamId: string | undefined
editingVariableName: string | null
isViewer: boolean
}>(),
{

View File

@@ -102,6 +102,7 @@
:action="action"
:editing-environment="editingEnvironment"
:editing-team-id="team?.id"
:editing-variable-name="editingVariableName"
:is-viewer="team?.myRole === 'VIEWER'"
@hide-modal="displayModalEdit(false)"
/>
@@ -125,6 +126,7 @@ import IconPlus from "~icons/lucide/plus"
import IconArchive from "~icons/lucide/archive"
import IconHelpCircle from "~icons/lucide/help-circle"
import { Team } from "~/helpers/backend/graphql"
import { defineActionHandler } from "~/helpers/actions"
const t = useI18n()
@@ -132,7 +134,7 @@ const colorMode = useColorMode()
type SelectedTeam = Team | undefined
defineProps<{
const props = defineProps<{
team: SelectedTeam
teamEnvironments: TeamEnvironment[]
adapterError: GQLError<string> | null
@@ -143,6 +145,7 @@ const showModalImportExport = ref(false)
const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironment = ref<TeamEnvironment | null>(null)
const editingVariableName = ref("")
const displayModalAdd = (shouldDisplay: boolean) => {
action.value = "new"
@@ -178,4 +181,15 @@ const getErrorMessage = (err: GQLError<string>) => {
}
}
}
defineActionHandler(
"modals.team.environment.edit",
({ envName, variableName }) => {
editingVariableName.value = variableName
const teamEnvToEdit = props.teamEnvironments.find(
(environment) => environment.environment.name === envName
)
if (teamEnvToEdit) editEnvironment(teamEnvToEdit)
}
)
</script>

View File

@@ -27,7 +27,7 @@ import {
keymap,
tooltips,
} from "@codemirror/view"
import { EditorState, Extension } from "@codemirror/state"
import { EditorSelection, EditorState, Extension } from "@codemirror/state"
import { clone } from "lodash-es"
import { history, historyKeymap } from "@codemirror/commands"
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
@@ -42,6 +42,7 @@ const props = withDefaults(
styles?: string
envs?: { key: string; value: string; source: string }[] | null
focus?: boolean
selectTextOnMount?: boolean
readonly?: boolean
}>(),
{
@@ -203,15 +204,28 @@ const initView = (el: any) => {
})
}
const triggerTextSelection = () => {
nextTick(() => {
view.value?.focus()
view.value?.dispatch({
selection: EditorSelection.create([
EditorSelection.range(0, props.modelValue.length),
]),
})
})
}
onMounted(() => {
if (editor.value) {
if (!view.value) initView(editor.value)
if (props.selectTextOnMount) triggerTextSelection()
}
})
watch(editor, () => {
if (editor.value) {
if (!view.value) initView(editor.value)
if (props.selectTextOnMount) triggerTextSelection()
} else {
view.value?.destroy()
view.value = undefined

View File

@@ -22,6 +22,8 @@ export type HoppAction =
| "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal
| "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment
| "navigation.jump.rest" // Jump to REST page
| "navigation.jump.graphql" // Jump to GraphQL page
| "navigation.jump.realtime" // Jump to realtime page
@@ -36,31 +38,101 @@ export type HoppAction =
| "response.file.download" // Download response as file
| "response.copy" // Copy response to clipboard
/**
* Defines the arguments, if present for a given type that is required to be passed on
* invocation and will be passed to action handlers.
*
* This type is supposed to be an object with the key being one of the actions mentioned above.
* The value to the key can be anything.
* If an action has no argument, you do not need to add it to this type.
*
* NOTE: We can't enforce type checks to make sure the key is Action, you
* will know if you got something wrong if there is a type error in this file
*/
type HoppActionArgs = {
"modals.my.environment.edit": {
envName: string
variableName: string
}
"modals.team.environment.edit": {
envName: string
variableName: string
}
}
/**
* HoppActions which require arguments for their invocation
*/
type HoppActionWithArgs = keyof HoppActionArgs
/**
* HoppActions which do not require arguments for their invocation
*/
type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
/**
* Resolves the argument type for a given HoppAction
*/
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
? HoppActionArgs[A]
: undefined
/**
* Resolves the action function for a given HoppAction, used by action handler function defs
*/
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
? (arg: ArgOfHoppAction<A>) => void
: () => void
type BoundActionList = {
// eslint-disable-next-line no-unused-vars
[_ in HoppAction]?: Array<() => void>
[A in HoppAction]?: Array<ActionFunc<A>>
}
const boundActions: BoundActionList = {}
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
export function bindAction(action: HoppAction, handler: () => void) {
export function bindAction<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
) {
if (boundActions[action]) {
boundActions[action]?.push(handler)
} else {
boundActions[action] = [handler]
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
boundActions[action] = [handler] as any
}
activeActions$.next(Object.keys(boundActions) as HoppAction[])
}
export function invokeAction(action: HoppAction) {
boundActions[action]?.forEach((handler) => handler())
type InvokeActionFunc = {
(action: HoppActionWithNoArgs, args?: undefined): void
<A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void
}
export function unbindAction(action: HoppAction, handler: () => void) {
boundActions[action] = boundActions[action]?.filter((x) => x !== handler)
/**
* Invokes a action, triggering action handlers if any registered.
* The second argument parameter is optional if your action has no args required
* @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required
*/
export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
action: A,
args: ArgOfHoppAction<A>
) => {
boundActions[action]?.forEach((handler) => handler(args!))
}
export function unbindAction<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
) {
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
boundActions[action] = boundActions[action]?.filter(
(x) => x !== handler
) as any
if (boundActions[action]?.length === 0) {
delete boundActions[action]
@@ -69,7 +141,10 @@ export function unbindAction(action: HoppAction, handler: () => void) {
activeActions$.next(Object.keys(boundActions) as HoppAction[])
}
export function defineActionHandler(action: HoppAction, handler: () => void) {
export function defineActionHandler<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
) {
onMounted(() => {
bindAction(action, handler)
})

View File

@@ -16,6 +16,7 @@ import {
getAggregateEnvs,
getSelectedEnvironmentType,
} from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions"
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
@@ -58,17 +59,13 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
)
return null
const envName =
aggregateEnvs.find(
(env) => env.key === text.slice(start - from, end - from)
// env.key === word.slice(wordSelection.from + 2, wordSelection.to - 2)
)?.sourceEnv ?? "Choose an Environment"
const parsedEnvKey = text.slice(start - from, end - from)
const envValue =
aggregateEnvs.find(
(env) => env.key === text.slice(start - from, end - from)
// env.key === word.slice(wordSelection.from + 2, wordSelection.to - 2)
)?.value ?? "Not found"
const tooltipEnv = aggregateEnvs.find((env) => env.key === parsedEnvKey)
const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment"
const envValue = tooltipEnv?.value ?? "Not found"
const result = parseTemplateStringE(envValue, aggregateEnvs)
@@ -76,9 +73,27 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const selectedEnvType = getSelectedEnvironmentType()
const envTypeIcon = `<i class="inline-flex items-center pr-2 mr-2 -my-1 text-base border-r material-icons border-secondary">${
const envTypeIcon = `<i class="inline-flex -my-1 -mx-0.5 opacity-65 items-center text-base material-icons border-secondary">${
selectedEnvType === "TEAM_ENV" ? "people" : "person"
}</i>`
const appendEditAction = (tooltip: HTMLElement) => {
const editIcon = document.createElement("span")
editIcon.className =
"env-icon ml-2 text-accent cursor-pointer hover:text-accentDark"
editIcon.addEventListener("click", () => {
const isPersonalEnv =
envName === "Global" || selectedEnvType !== "TEAM_ENV"
const action = isPersonalEnv ? "my" : "team"
invokeAction(`modals.${action}.environment.edit`, {
envName,
variableName: parsedEnvKey,
})
})
editIcon.innerHTML = `<i class="inline-flex -my-1 -mx-1 items-center px-1 text-base material-icons border-secondary">drive_file_rename_outline</i>`
tooltip.appendChild(editIcon)
}
return {
pos: start,
end: to,
@@ -90,11 +105,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const kbd = document.createElement("kbd")
const icon = document.createElement("span")
icon.innerHTML = envTypeIcon
icon.className = "env-icon"
icon.className = "env-icon mr-2"
kbd.textContent = finalEnv
tooltipContainer.appendChild(icon)
tooltipContainer.appendChild(document.createTextNode(`${envName} `))
tooltipContainer.appendChild(kbd)
if (tooltipEnv) appendEditAction(tooltipContainer)
tooltipContainer.className = "tippy-content"
dom.className = "tippy-box"
dom.dataset.theme = "tooltip"

View File

@@ -102,11 +102,14 @@ export const baseTheme = EditorView.theme({
border: "none",
borderRadius: "4px",
},
".cm-tooltip-arrow": {
color: "var(--tooltip-color)",
},
".cm-tooltip-arrow:after": {
borderTopColor: "var(--tooltip-color) !important",
borderTopColor: "inherit !important",
},
".cm-tooltip-arrow:before": {
borderTopColor: "var(--tooltip-color) !important",
borderTopColor: "inherit !important",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "var(--font-mono)",
@@ -234,11 +237,14 @@ export const inputTheme = EditorView.theme({
border: "none",
borderRadius: "4px",
},
".cm-tooltip-arrow": {
color: "var(--tooltip-color)",
},
".cm-tooltip-arrow:after": {
borderTopColor: "var(--tooltip-color) !important",
borderTopColor: "currentColor !important",
},
".cm-tooltip-arrow:before": {
borderTopColor: "var(--tooltip-color) !important",
borderTopColor: "currentColor !important",
},
".cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily: "var(--font-mono)",