refactor: keep tab dirty status logic at the page level

This commit is contained in:
jamesgeorge007
2024-04-25 18:14:22 +05:30
parent cd92dfec47
commit 197d253e8b
5 changed files with 177 additions and 173 deletions

View File

@@ -772,27 +772,6 @@ const onRemoveRootCollection = async () => {
return return
} }
const activeTabs = tabs.getActiveTabs()
for (const tab of activeTabs.value) {
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext?.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
if (requestHandle?.type === "invalid") {
continue
}
if (requestHandle!.data.requestID.startsWith(collectionIndexPath)) {
tab.document.saveContext = null
tab.document.isDirty = true
}
}
}
toast.success(t("state.deleted")) toast.success(t("state.deleted"))
displayConfirmModal(false) displayConfirmModal(false)
} }
@@ -1062,28 +1041,6 @@ const onRemoveChildCollection = async () => {
return return
} }
// TODO: Tab holding a request under the collection should be aware of the parent collection invalidation and toggle the dirty state
const activeTabs = tabs.getActiveTabs()
for (const tab of activeTabs.value) {
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext?.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
if (requestHandle?.type === "invalid") {
continue
}
if (requestHandle!.data.requestID.startsWith(parentCollectionIndexPath)) {
tab.document.saveContext = null
tab.document.isDirty = true
}
}
}
toast.success(t("state.deleted")) toast.success(t("state.deleted"))
displayConfirmModal(false) displayConfirmModal(false)
} }

View File

@@ -13,7 +13,7 @@
<HoppSmartWindow <HoppSmartWindow
v-for="tab in activeTabs" v-for="tab in activeTabs"
:id="tab.id" :id="tab.id"
:key="`${tab.id}-${tab.document.isDirty}`" :key="tab.id"
:label="tab.document.request.name" :label="tab.document.request.name"
:is-removable="activeTabs.length > 1" :is-removable="activeTabs.length > 1"
:close-visibility="'hover'" :close-visibility="'hover'"
@@ -26,12 +26,11 @@
@close-tab="removeTab(tab.id)" @close-tab="removeTab(tab.id)"
@close-other-tabs="closeOtherTabsAction(tab.id)" @close-other-tabs="closeOtherTabsAction(tab.id)"
@duplicate-tab="duplicateTab(tab.id)" @duplicate-tab="duplicateTab(tab.id)"
@share-tab-request="shareTabRequest(tab.id)"
/> />
</template> </template>
<template #suffix> <template #suffix>
<span <span
v-if="tab.document.isDirty" v-if="getTabDirtyStatus(tab)"
class="flex w-4 items-center justify-center text-secondary group-hover:hidden" class="flex w-4 items-center justify-center text-secondary group-hover:hidden"
> >
<svg <svg
@@ -64,6 +63,13 @@
@submit="renameReqName" @submit="renameReqName"
@hide-modal="showRenamingReqNameModal = false" @hide-modal="showRenamingReqNameModal = false"
/> />
<HoppSmartConfirmModal
:show="confirmingCloseForTabID !== null"
:confirm="t('modal.close_unsaved_tab')"
:title="t('confirm.save_unsaved_tab')"
@hide-modal="onCloseConfirmSaveTab"
@resolve="onResolveConfirmSaveTab"
/>
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmingCloseAllTabs" :show="confirmingCloseAllTabs"
:confirm="t('modal.close_unsaved_tab')" :confirm="t('modal.close_unsaved_tab')"
@@ -71,36 +77,6 @@
@hide-modal="confirmingCloseAllTabs = false" @hide-modal="confirmingCloseAllTabs = false"
@resolve="onResolveConfirmCloseAllTabs" @resolve="onResolveConfirmCloseAllTabs"
/> />
<HoppSmartModal
v-if="confirmingCloseForTabID !== null"
dialog
role="dialog"
aria-modal="true"
:title="t('modal.close_unsaved_tab')"
@close="confirmingCloseForTabID = null"
>
<template #body>
<div class="text-center">
{{ t("confirm.save_unsaved_tab") }}
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t?.('action.yes')"
outline
@click="onResolveConfirmSaveTab"
/>
<HoppButtonSecondary
:label="t?.('action.no')"
filled
outline
@click="onCloseConfirmSaveTab"
/>
</span>
</template>
</HoppSmartModal>
<CollectionsSaveRequest <CollectionsSaveRequest
v-if="savingRequest" v-if="savingRequest"
mode="rest" mode="rest"
@@ -120,23 +96,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { safelyExtractRESTRequest } from "@hoppscotch/data" import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { watchDebounced } from "@vueuse/core"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { onMounted, ref } from "vue" import {
BehaviorSubject,
EMPTY,
Subscription,
audit,
combineLatest,
from,
map,
} from "rxjs"
import { onBeforeUnmount, onMounted, ref } from "vue"
import { useRoute } from "vue-router" import { useRoute } from "vue-router"
import { onLoggedIn } from "~/composables/auth"
import { useReadonlyStream } from "~/composables/stream" import { useReadonlyStream } from "~/composables/stream"
import { useToast } from "~/composables/toast"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams" import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { defineActionHandler, invokeAction } from "~/helpers/actions" import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document" import { HoppRESTDocument } from "~/helpers/rest/document"
import {
changeCurrentSyncStatus,
currentSyncingStatus$,
} from "~/newstore/syncing"
import { platform } from "~/platform" import { platform } from "~/platform"
import { InspectionService } from "~/services/inspection" import { InspectionService } from "~/services/inspection"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector" import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector" import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector" import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { HandleRef } from "~/services/new-workspace/handle" import { HoppTab, PersistableTabState } from "~/services/tab"
import { WorkspaceRequest } from "~/services/new-workspace/workspace"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
const savingRequest = ref(false) const savingRequest = ref(false)
@@ -149,16 +139,12 @@ const exceptedTabID = ref<string | null>(null)
const renameTabID = ref<string | null>(null) const renameTabID = ref<string | null>(null)
const t = useI18n() const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService) const tabs = useService(RESTTabService)
const currentTabID = tabs.currentTabID const currentTabID = tabs.currentTabID
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
type PopupDetails = { type PopupDetails = {
show: boolean show: boolean
position: { position: {
@@ -179,6 +165,12 @@ const contextMenu = ref<PopupDetails>({
const activeTabs = tabs.getActiveTabs() const activeTabs = tabs.getActiveTabs()
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
isInitialSync: false,
shouldSync: true,
})
const tabStateForSync = ref<PersistableTabState<HoppRESTDocument> | null>(null)
function bindRequestToURLParams() { function bindRequestToURLParams() {
const route = useRoute() const route = useRoute()
// Get URL parameters and set that as the request // Get URL parameters and set that as the request
@@ -218,7 +210,7 @@ const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => { const removeTab = (tabID: string) => {
const tabState = tabs.getTabRef(tabID).value const tabState = tabs.getTabRef(tabID).value
if (tabState.document.isDirty) { if (getTabDirtyStatus(tabState)) {
confirmingCloseForTabID.value = tabID confirmingCloseForTabID.value = tabID
} else { } else {
tabs.closeTab(tabState.id) tabs.closeTab(tabState.id)
@@ -227,8 +219,10 @@ const removeTab = (tabID: string) => {
} }
const closeOtherTabsAction = (tabID: string) => { const closeOtherTabsAction = (tabID: string) => {
const isTabDirty = tabs.getTabRef(tabID).value?.document.isDirty
const dirtyTabCount = tabs.getDirtyTabsCount() const dirtyTabCount = tabs.getDirtyTabsCount()
const isTabDirty = getTabDirtyStatus(tabs.getTabRef(tabID).value)
// If current tab is dirty, so we need to subtract 1 from the dirty tab count // If current tab is dirty, so we need to subtract 1 from the dirty tab count
const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount
@@ -301,12 +295,7 @@ const onResolveConfirmSaveTab = () => {
if ( if (
!saveContext || !saveContext ||
(saveContext.originLocation === "workspace-user-collection" && (saveContext.originLocation === "workspace-user-collection" &&
// `requestHandle` gets unwrapped here saveContext.requestHandle?.value.type === "invalid")
(
saveContext.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
)?.type === "invalid")
) { ) {
return (savingRequest.value = true) return (savingRequest.value = true)
} }
@@ -330,17 +319,120 @@ const onSaveModalClose = () => {
} }
} }
const shareTabRequest = (tabID: string) => { const syncTabState = () => {
const tab = tabs.getTabRef(tabID) if (tabStateForSync.value)
if (tab.value) { tabs.loadTabsFromPersistedState(tabStateForSync.value)
if (currentUser.value) { }
invokeAction("share.request", {
request: tab.value.document.request, const getTabDirtyStatus = (tab: HoppTab<HoppRESTDocument>) => {
}) if (tab.document.isDirty) {
} else { return true
invokeAction("modals.login.toggle")
}
} }
return (
tab.document.saveContext?.originLocation === "workspace-user-collection" &&
tab.document.saveContext.requestHandle?.value.type === "invalid"
)
}
/**
* Performs sync of the REST Tab session with Firestore.
*
* @returns A subscription to the sync observable stream.
* Unsubscribe to stop syncing.
*/
function startTabStateSync(): Subscription {
const currentUser$ = platform.auth.getCurrentUserStream()
const tabState$ =
new BehaviorSubject<PersistableTabState<HoppRESTDocument> | null>(null)
watchDebounced(
tabs.persistableTabState,
(state) => {
tabState$.next(state)
},
{ debounce: 500, deep: true }
)
const sub = combineLatest([currentUser$, tabState$])
.pipe(
map(([user, tabState]) =>
user && tabState
? from(platform.sync.tabState.writeCurrentTabState(user, tabState))
: EMPTY
),
audit((x) => x)
)
.subscribe(() => {
// NOTE: This subscription should be kept
})
return sub
}
const showSyncToast = () => {
toast.show(t("confirm.sync"), {
duration: 0,
action: [
{
text: `${t("action.yes")}`,
onClick: (_, toastObject) => {
syncTabState()
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: true,
})
toastObject.goAway(0)
},
},
{
text: `${t("action.no")}`,
onClick: (_, toastObject) => {
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: false,
})
toastObject.goAway(0)
},
},
],
})
}
function setupTabStateSync() {
const route = useRoute()
// Subscription to request sync
let sub: Subscription | null = null
// Load request on login resolve and start sync
onLoggedIn(async () => {
if (
Object.keys(route.query).length === 0 &&
!(route.query.code || route.query.error)
) {
const tabStateFromSync =
await platform.sync.tabState.loadTabStateFromSync()
if (tabStateFromSync && !confirmSync.value.isInitialSync) {
tabStateForSync.value = tabStateFromSync
showSyncToast()
// Have to set isInitialSync to true here because the toast is shown
// and the user does not click on any of the actions
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: false,
})
}
}
sub = startTabStateSync()
})
// Stop subscription to stop syncing
onBeforeUnmount(() => {
sub?.unsubscribe()
})
} }
defineActionHandler("contextmenu.open", ({ position, text }) => { defineActionHandler("contextmenu.open", ({ position, text }) => {
@@ -359,6 +451,7 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
} }
}) })
setupTabStateSync()
bindRequestToURLParams() bindRequestToURLParams()
defineActionHandler("rest.request.open", ({ doc }) => { defineActionHandler("rest.request.open", ({ doc }) => {
@@ -384,5 +477,3 @@ for (const inspectorDef of platform.additionalInspectors ?? []) {
useService(inspectorDef.service) useService(inspectorDef.service)
} }
</script> </script>
import { HandleRef } from "~/services/new-workspace/handle" import {
WorkspaceRequest } from "~/services/new-workspace/workspace"

View File

@@ -11,6 +11,7 @@ import {
computed, computed,
effectScope, effectScope,
markRaw, markRaw,
nextTick,
ref, ref,
shallowRef, shallowRef,
watch, watch,
@@ -302,15 +303,18 @@ export class PersonalWorkspaceProviderService
) )
} }
for (const handle of this.issuedHandles) { for (const [idx, handle] of this.issuedHandles.entries()) {
if (handle.value.type === "invalid") continue if (handle.value.type === "invalid") continue
if ("requestID" in handle.value.data) { if ("requestID" in handle.value.data) {
if (handle.value.data.requestID.startsWith(collectionID)) { if (handle.value.data.requestID.startsWith(collectionID)) {
handle.value = { // @ts-expect-error - We're deleting the data to invalidate the handle
type: "invalid", delete this.issuedHandles[idx].value.data
reason: "REQUEST_INVALIDATED",
} this.issuedHandles[idx].value.type = "invalid"
// @ts-expect-error - Setting the handle invalidation reason
this.issuedHandles[idx].value.reason = "REQUEST_INVALIDATED"
} }
} }
} }
@@ -403,15 +407,18 @@ export class PersonalWorkspaceProviderService
removeRESTRequest(collectionID, requestIndex, requestToRemove?.id) removeRESTRequest(collectionID, requestIndex, requestToRemove?.id)
for (const handle of this.issuedHandles) { for (const [idx, handle] of this.issuedHandles.entries()) {
if (handle.value.type === "invalid") continue if (handle.value.type === "invalid") continue
if ("requestID" in handle.value.data) { if ("requestID" in handle.value.data) {
if (handle.value.data.requestID === requestID) { if (handle.value.data.requestID === requestID) {
handle.value = { // @ts-expect-error - We're deleting the data to invalidate the handle
type: "invalid", delete this.issuedHandles[idx].value.data
reason: "REQUEST_INVALIDATED",
} this.issuedHandles[idx].value.type = "invalid"
// @ts-expect-error - Setting the handle invalidation reason
this.issuedHandles[idx].value.reason = "REQUEST_INVALIDATED"
} }
} }
} }

View File

@@ -35,12 +35,11 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
lastActiveTabID: this.currentTabID.value, lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => { orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
const resolvedTabData = this.getResolvedTabData(tab)
return { return {
tabID: tab.id, tabID: tab.id,
doc: { doc: {
...this.getPersistedDocument(resolvedTabData.document), ...this.getPersistedDocument(tab.document),
response: null, response: null,
}, },
} }

View File

@@ -46,10 +46,7 @@ export abstract class TabService<Doc>
}) })
public currentActiveTab = computed(() => { public currentActiveTab = computed(() => {
const tab = this.tabMap.get(this.currentTabID.value)! return this.tabMap.get(this.currentTabID.value)!
return this.getResolvedTabData(
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
)
}) // Guaranteed to not be undefined }) // Guaranteed to not be undefined
protected watchCurrentTabID() { protected watchCurrentTabID() {
@@ -87,15 +84,7 @@ export abstract class TabService<Doc>
} }
public getActiveTab(): HoppTab<Doc> | null { public getActiveTab(): HoppTab<Doc> | null {
const tab = this.tabMap.get(this.currentTabID.value) return this.tabMap.get(this.currentTabID.value) ?? null
if (!tab) {
return null
}
return this.getResolvedTabData(
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
)
} }
public setActiveTab(tabID: string): void { public setActiveTab(tabID: string): void {
@@ -172,13 +161,7 @@ export abstract class TabService<Doc>
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> { public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
return shallowReadonly( return shallowReadonly(
computed(() => computed(() =>
this.tabOrdering.value.map((x) => { this.tabOrdering.value.map((x) => this.tabMap.get(x) as HoppTab<Doc>)
const tab = this.tabMap.get(x) as HoppTab<
HoppRESTDocument | HoppGQLDocument
>
return this.getResolvedTabData(tab)
})
) )
) )
} }
@@ -186,13 +169,11 @@ export abstract class TabService<Doc>
public getTabRef(tabID: string) { public getTabRef(tabID: string) {
return computed({ return computed({
get: () => { get: () => {
const result = this.tabMap.get(tabID) as HoppTab< const result = this.tabMap.get(tabID)
HoppRESTDocument | HoppGQLDocument
>
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`) if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return this.getResolvedTabData(result) return result
}, },
set: (value) => { set: (value) => {
return this.tabMap.set(tabID, value) return this.tabMap.set(tabID, value)
@@ -304,13 +285,10 @@ export abstract class TabService<Doc>
lastActiveTabID: this.currentTabID.value, lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => { orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
const resolvedTabData = this.getResolvedTabData(
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
)
return { return {
tabID: tab.id, tabID: tab.id,
doc: this.getPersistedDocument(resolvedTabData.document), doc: this.getPersistedDocument(tab.document),
} }
}), }),
})) }))
@@ -328,32 +306,4 @@ export abstract class TabService<Doc>
if (!this.tabMap.has(id)) return id if (!this.tabMap.has(id)) return id
} }
} }
protected getResolvedTabData(
tab: HoppTab<HoppRESTDocument | HoppGQLDocument>
): HoppTab<Doc> {
if (
tab.document.isDirty ||
!tab.document.saveContext ||
tab.document.saveContext.originLocation !== "workspace-user-collection"
) {
return tab as HoppTab<Doc>
}
const requestHandle = tab.document.saveContext.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
if (!requestHandle) {
return tab as HoppTab<Doc>
}
return {
...tab,
document: {
...tab.document,
isDirty: requestHandle.type === "invalid",
},
} as HoppTab<Doc>
}
} }