refactor: introduce writable handles to signify updates to handle references

A special list of writable handles is compiled in a list while issuing handles (request/collection creation, etc). Instead of manually computing the tab and toggling the dirty state, the writable handle is updated (changing the type to invalid on request deletion) and the tab with the request open can infer it via the update reflected in the request handle under the tab save context (reactive update trigger).
This commit is contained in:
jamesgeorge007
2024-04-23 22:21:13 +05:30
parent 8467417e7a
commit cd92dfec47
7 changed files with 230 additions and 110 deletions

View File

@@ -1118,15 +1118,6 @@ const onRemoveRequest = async () => {
return return
} }
const { providerID, requestID, workspaceID } = requestHandle.value.data
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "workspace-user-collection",
workspaceID,
providerID,
requestID,
})
if ( if (
isSelected({ isSelected({
requestIndex: parseInt(requestIndexPath.split("/").pop() ?? ""), requestIndex: parseInt(requestIndexPath.split("/").pop() ?? ""),
@@ -1143,12 +1134,6 @@ const onRemoveRequest = async () => {
return return
} }
// If there is a tab attached to this request, dissociate its state and mark it dirty
if (possibleTab) {
possibleTab.value.document.saveContext = null
possibleTab.value.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" :key="`${tab.id}-${tab.document.isDirty}`"
: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'"
@@ -118,24 +118,26 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest" import { onMounted, ref } from "vue"
import { HoppTab } from "~/services/tab" import { useRoute } from "vue-router"
import { useReadonlyStream } from "~/composables/stream"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document" import { HoppRESTDocument } from "~/helpers/rest/document"
import { platform } from "~/platform"
import { InspectionService } from "~/services/inspection"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { HandleRef } from "~/services/new-workspace/handle"
import { WorkspaceRequest } from "~/services/new-workspace/workspace"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest"
const savingRequest = ref(false) const savingRequest = ref(false)
const confirmingCloseForTabID = ref<string | null>(null) const confirmingCloseForTabID = ref<string | null>(null)
@@ -291,15 +293,29 @@ const onCloseConfirmSaveTab = () => {
* Called when the user confirms they want to save the tab * Called when the user confirms they want to save the tab
*/ */
const onResolveConfirmSaveTab = () => { const onResolveConfirmSaveTab = () => {
if (tabs.currentActiveTab.value.document.saveContext) { const { saveContext } = tabs.currentActiveTab.value.document
invokeAction("request.save")
if (confirmingCloseForTabID.value) { // There're two cases where the save request under a collection modal should open
tabs.closeTab(confirmingCloseForTabID.value) // 1. Attempting to save a request that is not under a collection (When the save context is not available)
confirmingCloseForTabID.value = null // 2. Deleting a request from the collection tree and attempting to save it while closing the respective tab (When the request handle is invalid)
} if (
} else { !saveContext ||
savingRequest.value = true (saveContext.originLocation === "workspace-user-collection" &&
// `requestHandle` gets unwrapped here
(
saveContext.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
)?.type === "invalid")
) {
return (savingRequest.value = true)
}
invokeAction("request.save")
if (confirmingCloseForTabID.value) {
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
} }
} }
@@ -368,3 +384,5 @@ 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

@@ -121,6 +121,11 @@ export class InspectionService extends Service {
} }
private initializeListeners() { private initializeListeners() {
console.log(
`Current active tab from inspection service is `,
this.restTab.currentActiveTab.value
)
watch( watch(
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id], () => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => { () => {

View File

@@ -1,5 +1,12 @@
import { Ref } from "vue" import { Ref, WritableComputedRef } from "vue"
export type HandleRef<T, InvalidateReason = unknown> = Ref< export type HandleRef<T, InvalidateReason = unknown> = Ref<
{ type: "ok"; data: T } | { type: "invalid"; reason: InvalidateReason } { type: "ok"; data: T } | { type: "invalid"; reason: InvalidateReason }
> >
export type WritableHandleRef<
T,
InvalidateReason = unknown,
> = WritableComputedRef<
{ type: "ok"; data: T } | { type: "invalid"; reason: InvalidateReason }
>

View File

@@ -39,7 +39,7 @@ import {
} from "~/newstore/collections" } from "~/newstore/collections"
import { platform } from "~/platform" import { platform } from "~/platform"
import { HandleRef } from "~/services/new-workspace/handle" import { HandleRef, WritableHandleRef } from "~/services/new-workspace/handle"
import { WorkspaceProvider } from "~/services/new-workspace/provider" import { WorkspaceProvider } from "~/services/new-workspace/provider"
import { import {
RESTCollectionChildrenView, RESTCollectionChildrenView,
@@ -87,6 +87,10 @@ export class PersonalWorkspaceProviderService
private restCollectionState: Ref<{ state: HoppCollection[] }> private restCollectionState: Ref<{ state: HoppCollection[] }>
private issuedHandles: WritableHandleRef<
WorkspaceCollection | WorkspaceRequest
>[] = []
public constructor() { public constructor() {
super() super()
@@ -298,6 +302,19 @@ export class PersonalWorkspaceProviderService
) )
} }
for (const handle of this.issuedHandles) {
if (handle.value.type === "invalid") continue
if ("requestID" in handle.value.data) {
if (handle.value.data.requestID.startsWith(collectionID)) {
handle.value = {
type: "invalid",
reason: "REQUEST_INVALIDATED",
}
}
}
}
return Promise.resolve(E.right(undefined)) return Promise.resolve(E.right(undefined))
} }
@@ -329,35 +346,44 @@ export class PersonalWorkspaceProviderService
platform: "rest", platform: "rest",
}) })
return Promise.resolve( const handle: HandleRef<WorkspaceRequest> = computed(() => {
E.right( if (
computed(() => { !isValidCollectionHandle(
if ( parentCollectionHandle,
!isValidCollectionHandle( this.providerID,
parentCollectionHandle, "personal"
this.providerID, )
"personal" ) {
) return {
) { type: "invalid" as const,
return { reason: "COLLECTION_INVALIDATED" as const,
type: "invalid" as const, }
reason: "COLLECTION_INVALIDATED" as const, }
}
}
return { return {
type: "ok", type: "ok",
data: { data: {
providerID, providerID,
workspaceID, workspaceID,
collectionID, collectionID,
requestID, requestID,
request: newRequest, request: newRequest,
}, },
} }
}) })
)
) const writableHandle = computed({
get() {
return handle.value
},
set(newValue) {
handle.value = newValue
},
})
this.issuedHandles.push(writableHandle)
return Promise.resolve(E.right(handle))
} }
public removeRESTRequest( public removeRESTRequest(
@@ -377,6 +403,19 @@ export class PersonalWorkspaceProviderService
removeRESTRequest(collectionID, requestIndex, requestToRemove?.id) removeRESTRequest(collectionID, requestIndex, requestToRemove?.id)
for (const handle of this.issuedHandles) {
if (handle.value.type === "invalid") continue
if ("requestID" in handle.value.data) {
if (handle.value.data.requestID === requestID) {
handle.value = {
type: "invalid",
reason: "REQUEST_INVALIDATED",
}
}
}
}
return Promise.resolve(E.right(undefined)) return Promise.resolve(E.right(undefined))
} }
@@ -647,35 +686,42 @@ export class PersonalWorkspaceProviderService
return Promise.resolve(E.left("REQUEST_NOT_FOUND" as const)) return Promise.resolve(E.left("REQUEST_NOT_FOUND" as const))
} }
return Promise.resolve( const handleRefData = ref({
E.right( type: "ok" as const,
computed(() => { data: {
if ( providerID,
!isValidWorkspaceHandle( workspaceID,
workspaceHandle, collectionID,
this.providerID, requestID,
"personal" request,
) },
) { })
return {
type: "invalid" as const,
reason: "WORKSPACE_INVALIDATED" as const,
}
}
return { const handle: HandleRef<WorkspaceRequest> = computed(() => {
type: "ok", if (
data: { !isValidWorkspaceHandle(workspaceHandle, this.providerID, "personal")
providerID, ) {
workspaceID, return {
collectionID, type: "invalid" as const,
requestID, reason: "WORKSPACE_INVALIDATED" as const,
request, }
}, }
}
}) return handleRefData.value
) })
)
const writableHandle = computed({
get() {
return handleRefData.value
},
set(newValue) {
handleRefData.value = newValue
},
})
this.issuedHandles.push(writableHandle)
return Promise.resolve(E.right(handle))
} }
public getRESTCollectionChildrenView( public getRESTCollectionChildrenView(

View File

@@ -35,17 +35,19 @@ 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: {
...tab.document, ...this.getPersistedDocument(resolvedTabData.document),
response: null, response: null,
}, },
} }
}), }),
})) }))
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) { public getTabRefWithSaveContext(ctx: Partial<HoppRESTSaveContext>) {
for (const tab of this.tabMap.values()) { for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique // For `team-collection` request id can be considered unique
if (ctx?.originLocation === "team-collection") { if (ctx?.originLocation === "team-collection") {

View File

@@ -18,6 +18,7 @@ import {
TabService as TabServiceInterface, TabService as TabServiceInterface,
} from "." } from "."
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { NewWorkspaceService } from "../new-workspace" import { NewWorkspaceService } from "../new-workspace"
import { HandleRef } from "../new-workspace/handle" import { HandleRef } from "../new-workspace/handle"
import { WorkspaceRequest } from "../new-workspace/workspace" import { WorkspaceRequest } from "../new-workspace/workspace"
@@ -44,9 +45,12 @@ export abstract class TabService<Doc>
}, },
}) })
public currentActiveTab = computed( public currentActiveTab = computed(() => {
() => this.tabMap.get(this.currentTabID.value)! const tab = this.tabMap.get(this.currentTabID.value)!
) // Guaranteed to not be undefined return this.getResolvedTabData(
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
)
}) // Guaranteed to not be undefined
protected watchCurrentTabID() { protected watchCurrentTabID() {
watch( watch(
@@ -83,7 +87,15 @@ export abstract class TabService<Doc>
} }
public getActiveTab(): HoppTab<Doc> | null { public getActiveTab(): HoppTab<Doc> | null {
return this.tabMap.get(this.currentTabID.value) ?? null const tab = this.tabMap.get(this.currentTabID.value)
if (!tab) {
return null
}
return this.getResolvedTabData(
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
)
} }
public setActiveTab(tabID: string): void { public setActiveTab(tabID: string): void {
@@ -159,18 +171,28 @@ export abstract class TabService<Doc>
} }
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> { public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
return shallowReadonly( return shallowReadonly(
computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!)) computed(() =>
this.tabOrdering.value.map((x) => {
const tab = this.tabMap.get(x) as HoppTab<
HoppRESTDocument | HoppGQLDocument
>
return this.getResolvedTabData(tab)
})
)
) )
} }
public getTabRef(tabID: string) { public getTabRef(tabID: string) {
return computed({ return computed({
get: () => { get: () => {
const result = this.tabMap.get(tabID) const result = this.tabMap.get(tabID) as HoppTab<
HoppRESTDocument | HoppGQLDocument
>
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`) if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result return this.getResolvedTabData(result)
}, },
set: (value) => { set: (value) => {
return this.tabMap.set(tabID, value) return this.tabMap.set(tabID, value)
@@ -237,20 +259,23 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID this.currentTabID.value = tabID
} }
private getPersistedDocument(tabDoc: Doc): Doc { public getPersistedDocument(tabDoc: Doc): Doc {
const { saveContext } = tabDoc as HoppRESTDocument const { saveContext } = tabDoc as HoppRESTDocument
if (saveContext?.originLocation !== "workspace-user-collection") { if (saveContext?.originLocation !== "workspace-user-collection") {
return tabDoc return tabDoc
} }
const { requestHandle } = saveContext // TODO: Investigate why requestHandle is available unwrapped here
const requestHandle = saveContext.requestHandle as
| HandleRef<WorkspaceRequest>["value"]
| undefined
if (!requestHandle) { if (!requestHandle) {
return tabDoc return tabDoc
} }
if (requestHandle.value.type === "invalid") { if (requestHandle.type === "invalid") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { requestHandle, ...rest } = saveContext const { requestHandle, ...rest } = saveContext
@@ -261,7 +286,7 @@ export abstract class TabService<Doc>
} }
} }
const { providerID, workspaceID, requestID } = requestHandle.value.data const { providerID, workspaceID, requestID } = requestHandle.data
// Return the document without the handle // Return the document without the handle
return { return {
@@ -279,9 +304,13 @@ 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(tab.document), doc: this.getPersistedDocument(resolvedTabData.document),
} }
}), }),
})) }))
@@ -299,4 +328,32 @@ 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>
}
} }