Browse Source

feat(common): introducing history ui provider service to hoppscotch-common (#4706)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Joel Jacob Stephen 1 month ago
parent
commit
0a83894e6a

+ 30 - 0
packages/hoppscotch-common/locales/en.json

@@ -67,6 +67,25 @@
     "enable": "Enable",
     "disable": "Disable"
   },
+  "activity_logs": {
+    "ACTIVITY_LOG_DELETE": "Activity log has been deleted",
+    "WORKSPACE_CREATE": "Created new workspace {name}",
+    "WORKSPACE_RENAME": "Renamed workspace from {old_name} to {new_name}",
+    "WORKSPACE_USER_ADD": "{user} was added to the workspace as {role}",
+    "WORKSPACE_USER_INVITE": "{user} was invited by {inviteeEmail} as {role}",
+    "WORKSPACE_USER_INVITE_REVOKE": "Revoked invitation of {inviteeEmail} as {inviteeRole}",
+    "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} accepted the invitation as {inviteeRole}",
+    "WORKSPACE_USER_REMOVE": "{user} was removed from the workspace",
+    "WORKSPACE_USER_ROLE_UPDATE": "{user}'s role was updated from {old_role} to {new_role}",
+    "COLLECTION_CREATE": "Created new collection {title}",
+    "COLLECTION_RENAME": "Renamed collection from {old_title} to {new_title}",
+    "COLLECTION_IMPORT": "Imported collection {title}",
+    "COLLECTION_DELETE": "Deleted collection {title}",
+    "COLLECTION_DUPLICATE": "Duplicated collection {parentTitle}",
+    "REQUEST_CREATE": "Created new request {title}",
+    "REQUEST_RENAME": "Renamed request from {old_title} to {new_title}",
+    "REQUEST_DELETE": "Deleted request {title}"
+  },
   "add": {
     "new": "Add new",
     "star": "Add star"
@@ -263,6 +282,7 @@
   "confirm": {
     "close_unsaved_tab": "Are you sure you want to close this tab?",
     "close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
+    "delete_all_activity_logs": "Are you sure you want to delete all activity logs?",
     "exit_team": "Are you sure you want to leave this workspace?",
     "logout": "Are you sure you want to logout?",
     "remove_collection": "Are you sure you want to permanently delete this collection?",
@@ -317,6 +337,7 @@
     "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go."
   },
   "empty": {
+    "activity_logs": "No activity logs found",
     "authorization": "This request does not use any authorization",
     "body": "This request does not have a body",
     "collection": "Collection is empty",
@@ -395,8 +416,11 @@
     "danger_zone": "Danger zone",
     "delete_account": "Your account is currently an owner in these workspaces:",
     "delete_account_description": "You must either remove yourself, transfer ownership, or delete these workspaces before you can delete your account.",
+    "delete_activity_log": "Failed to delete activity log",
+    "delete_all_activity_logs": "Failed to delete all activity logs",
     "empty_profile_name": "Profile name cannot be empty",
     "empty_req_name": "Empty Request Name",
+    "fetch_activity_logs": "Failed to fetch activity logs",
     "f12_details": "(F12 for details)",
     "gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
     "incomplete_config_urls": "Incomplete configuration URLs",
@@ -419,6 +443,7 @@
     "same_profile_name": "Updated profile name is same as the current profile name",
     "script_fail": "Could not execute pre-request script",
     "something_went_wrong": "Something went wrong",
+    "subscription_error": "Failed to subscribe to the topic: {error}",
     "test_script_fail": "Could not execute post-request script",
     "reading_files": "Error while reading one or more files.",
     "fetching_access_tokens_list": "Something went wrong while fetching the list of tokens",
@@ -1096,9 +1121,14 @@
     "failed": "Failed"
   },
   "team": {
+    "activity_logs": "Activity Logs",
     "already_member": "This email is associated with an existing user.",
     "create_new": "Create new workspace",
     "deleted": "Workspace deleted",
+    "delete_all_activity_logs": "Delete all activity logs",
+    "delete_activity_log": "Delete activity log",
+    "deleted_activity_log": "Deleted selected activity log",
+    "deleted_all_activity_logs": "Deleted all activity logs",
     "edit": "Edit Workspace",
     "email": "E-mail",
     "email_do_not_match": "Email doesn't match with your account details. Contact your workspace owner.",

+ 349 - 0
packages/hoppscotch-common/src/components/history/Personal.vue

@@ -0,0 +1,349 @@
+<template>
+  <div>
+    <div
+      class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
+    >
+      <div class="flex">
+        <input
+          v-model="filterText"
+          type="search"
+          autocomplete="off"
+          class="flex w-full bg-transparent px-4 py-2 h-8"
+          :placeholder="`${t('action.search')}`"
+        />
+        <div class="flex">
+          <HoppButtonSecondary
+            v-tippy="{ theme: 'tooltip' }"
+            to="https://docs.hoppscotch.io/documentation/features/history"
+            blank
+            :title="t('app.wiki')"
+            :icon="IconHelpCircle"
+          />
+          <tippy interactive trigger="click" theme="popover">
+            <HoppButtonSecondary
+              v-tippy="{ theme: 'tooltip' }"
+              :title="t('action.filter')"
+              :icon="IconFilter"
+            />
+            <template #content="{ hide }">
+              <div ref="tippyActions" class="flex flex-col focus:outline-none">
+                <div class="pb-2 pl-4 text-tiny text-secondaryLight">
+                  {{ t("action.filter") }}
+                </div>
+                <HoppSmartRadioGroup
+                  v-model="filterSelection"
+                  :radios="filters"
+                  @update:model-value="hide()"
+                />
+                <hr />
+                <div class="pb-2 pl-4 text-tiny text-secondaryLight">
+                  {{ t("action.group_by") }}
+                </div>
+                <HoppSmartRadioGroup
+                  v-model="groupSelection"
+                  :radios="groups"
+                  @update:model-value="hide()"
+                />
+              </div>
+            </template>
+          </tippy>
+          <HoppButtonSecondary
+            v-tippy="{ theme: 'tooltip' }"
+            data-testid="clear_history"
+            :disabled="history.length === 0"
+            :icon="IconTrash2"
+            :title="t('action.clear_all')"
+            @click="confirmRemove = true"
+          />
+        </div>
+      </div>
+    </div>
+    <div class="flex flex-col">
+      <details
+        v-for="(
+          filteredHistoryGroup, filteredHistoryGroupIndex
+        ) in filteredHistoryGroups"
+        :key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
+        class="flex flex-col"
+        open
+      >
+        <summary
+          class="group flex min-w-0 flex-1 cursor-pointer items-center justify-between text-tiny text-secondaryLight transition focus:outline-none"
+        >
+          <span
+            class="inline-flex items-center justify-center truncate px-4 py-2 transition group-hover:text-secondary"
+          >
+            <icon-lucide-chevron-right
+              class="indicator mr-2 flex flex-shrink-0"
+            />
+            <span
+              :class="[
+                { 'capitalize-first': groupSelection === 'TIME' },
+                'truncate',
+              ]"
+            >
+              {{ filteredHistoryGroupIndex }}
+            </span>
+          </span>
+          <HoppButtonSecondary
+            v-tippy="{ theme: 'tooltip' }"
+            :icon="IconTrash"
+            color="red"
+            :title="t('action.remove')"
+            class="hidden group-hover:inline-flex"
+            @click="deleteBatchHistoryEntry(filteredHistoryGroup)"
+          />
+        </summary>
+        <component
+          :is="page === 'rest' ? HistoryRestCard : HistoryGraphqlCard"
+          v-for="(entry, index) in filteredHistoryGroup"
+          :id="index"
+          :key="`entry-${index}`"
+          :entry="entry.entry"
+          :show-more="showMore"
+          @toggle-star="toggleStar(entry.entry)"
+          @delete-entry="deleteHistory(entry.entry)"
+          @use-entry="useHistory(toRaw(entry.entry))"
+          @add-to-collection="addToCollection(entry.entry)"
+        />
+      </details>
+    </div>
+    <HoppSmartPlaceholder
+      v-if="history.length === 0"
+      :src="`/images/states/${colorMode.value}/time.svg`"
+      :alt="`${t('empty.history')}`"
+      :text="t('empty.history')"
+    />
+    <HoppSmartPlaceholder
+      v-else-if="
+        Object.keys(filteredHistoryGroups).length === 0 ||
+        filteredHistory.length === 0
+      "
+      :text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
+    >
+      <template #icon>
+        <icon-lucide-search class="svg-icons opacity-75" />
+      </template>
+      <template #body>
+        <HoppButtonSecondary
+          :label="t('action.clear')"
+          outline
+          @click="
+            () => {
+              filterText = ''
+              filterSelection = 'ALL'
+            }
+          "
+        />
+      </template>
+    </HoppSmartPlaceholder>
+    <HoppSmartConfirmModal
+      :show="confirmRemove"
+      :title="`${t('confirm.remove_history')}`"
+      @hide-modal="confirmRemove = false"
+      @resolve="clearHistory"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import IconHelpCircle from "~icons/lucide/help-circle"
+import IconTrash2 from "~icons/lucide/trash-2"
+import IconTrash from "~icons/lucide/trash"
+import IconFilter from "~icons/lucide/filter"
+import { computed, ref, Ref, toRaw } from "vue"
+import { useColorMode } from "@composables/theming"
+import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
+import { groupBy, escapeRegExp, filter } from "lodash-es"
+import { useTimeAgo } from "@vueuse/core"
+import { pipe } from "fp-ts/function"
+import * as A from "fp-ts/Array"
+import { useI18n } from "@composables/i18n"
+import { useReadonlyStream } from "@composables/stream"
+import { useToast } from "@composables/toast"
+import {
+  restHistory$,
+  graphqlHistory$,
+  clearRESTHistory,
+  clearGraphqlHistory,
+  toggleGraphqlHistoryEntryStar,
+  toggleRESTHistoryEntryStar,
+  deleteGraphqlHistoryEntry,
+  deleteRESTHistoryEntry,
+  RESTHistoryEntry,
+  GQLHistoryEntry,
+} from "~/newstore/history"
+
+import HistoryRestCard from "./rest/Card.vue"
+import HistoryGraphqlCard from "./graphql/Card.vue"
+import { defineActionHandler, invokeAction } from "~/helpers/actions"
+import { useService } from "dioc/vue"
+import { RESTTabService } from "~/services/tab/rest"
+
+type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
+
+type TimedHistoryEntry = {
+  entry: HistoryEntry
+  timeAgo: Ref<string>
+}
+
+const props = defineProps<{
+  page: "rest" | "graphql"
+}>()
+
+const toast = useToast()
+const t = useI18n()
+const colorMode = useColorMode()
+
+const filterText = ref("")
+const showMore = ref(false)
+const confirmRemove = ref(false)
+
+const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
+  props.page === "rest" ? restHistory$ : graphqlHistory$,
+  []
+)
+
+const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
+  if (value === null || value === undefined) return false
+
+  if (typeof value === "string") return regExp.test(value)
+  if (typeof value === "number") return regExp.test(value.toString())
+
+  if (typeof value === "object")
+    return Object.values(value).some((input) =>
+      deepCheckForRegex(input, regExp)
+    )
+  if (Array.isArray(value))
+    return value.some((input) => deepCheckForRegex(input, regExp))
+
+  return false
+}
+
+const filteredHistory = computed(() =>
+  pipe(
+    history.value as HistoryEntry[],
+    A.filter(
+      (
+        input
+      ): input is HistoryEntry & {
+        updatedOn: NonNullable<HistoryEntry["updatedOn"]>
+      } => {
+        return (
+          !!input.updatedOn &&
+          (filterText.value.length === 0 ||
+            deepCheckForRegex(
+              input,
+              new RegExp(escapeRegExp(filterText.value), "gi")
+            ))
+        )
+      }
+    ),
+    A.map(
+      (entry): TimedHistoryEntry => ({
+        entry,
+        timeAgo: useTimeAgo(entry.updatedOn),
+      })
+    )
+  )
+)
+
+const filters = computed(() => [
+  { value: "ALL" as const, label: t("filter.all") },
+  { value: "STARRED" as const, label: t("filter.starred") },
+])
+
+type FilterMode = (typeof filters)["value"][number]["value"]
+
+const filterSelection = ref<FilterMode>("ALL")
+
+const groups = computed(() => [
+  { value: "TIME" as const, label: t("group.time") },
+  { value: "URL" as const, label: t("group.url") },
+])
+
+type GroupMode = (typeof groups)["value"][number]["value"]
+
+const groupSelection = ref<GroupMode>("TIME")
+
+const filteredHistoryGroups = computed(() =>
+  groupBy(
+    filter(filteredHistory.value, (input) =>
+      filterSelection.value === "STARRED" ? input.entry.star : true
+    ),
+    (input) =>
+      groupSelection.value === "TIME"
+        ? input.timeAgo.value
+        : getAppropriateURL(input.entry)
+  )
+)
+
+const getAppropriateURL = (entry: HistoryEntry) => {
+  if (props.page === "rest") {
+    return (entry.request as HoppRESTRequest).endpoint
+  } else if (props.page === "graphql") {
+    return (entry.request as HoppGQLRequest).url
+  }
+}
+
+const clearHistory = () => {
+  if (props.page === "rest") clearRESTHistory()
+  else clearGraphqlHistory()
+  toast.success(`${t("state.history_deleted")}`)
+}
+
+// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
+// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
+const tabs = useService(RESTTabService)
+const useHistory = (entry: RESTHistoryEntry) => {
+  tabs.createNewTab({
+    request: entry.request,
+    isDirty: false,
+  })
+}
+
+const isRESTHistoryEntry = (
+  entries: TimedHistoryEntry[]
+): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
+  // If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
+  props.page === "rest"
+
+const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
+  if (isRESTHistoryEntry(entries)) {
+    entries.forEach((entry) => {
+      deleteRESTHistoryEntry(entry.entry)
+    })
+  } else {
+    entries.forEach((entry) => {
+      deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
+    })
+  }
+  toast.success(`${t("state.deleted")}`)
+}
+
+const deleteHistory = (entry: HistoryEntry) => {
+  if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
+  else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
+  toast.success(`${t("state.deleted")}`)
+}
+
+const addToCollection = (entry: HistoryEntry) => {
+  if (props.page === "rest") {
+    invokeAction("request.save-as", {
+      requestType: "rest",
+      request: entry.request as HoppRESTRequest,
+    })
+  }
+}
+
+const toggleStar = (entry: HistoryEntry) => {
+  // History entry type specified because function does not know the type
+  if (props.page === "rest")
+    toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
+  else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
+}
+
+defineActionHandler("history.clear", () => {
+  confirmRemove.value = true
+})
+</script>

+ 29 - 360
packages/hoppscotch-common/src/components/history/index.vue

@@ -1,375 +1,44 @@
 <template>
-  <div>
-    <div
-      class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
-    >
-      <WorkspaceCurrent :section="t('tab.history')" :is-only-personal="true" />
-      <div class="flex">
-        <input
-          v-model="filterText"
-          type="search"
-          autocomplete="off"
-          class="flex w-full bg-transparent px-4 py-2 h-8"
-          :placeholder="`${t('action.search')}`"
-        />
-        <div class="flex">
-          <HoppButtonSecondary
-            v-tippy="{ theme: 'tooltip' }"
-            to="https://docs.hoppscotch.io/documentation/features/history"
-            blank
-            :title="t('app.wiki')"
-            :icon="IconHelpCircle"
-          />
-          <tippy interactive trigger="click" theme="popover">
-            <HoppButtonSecondary
-              v-tippy="{ theme: 'tooltip' }"
-              :title="t('action.filter')"
-              :icon="IconFilter"
-            />
-            <template #content="{ hide }">
-              <div ref="tippyActions" class="flex flex-col focus:outline-none">
-                <div class="pb-2 pl-4 text-tiny text-secondaryLight">
-                  {{ t("action.filter") }}
-                </div>
-                <HoppSmartRadioGroup
-                  v-model="filterSelection"
-                  :radios="filters"
-                  @update:model-value="hide()"
-                />
-                <hr />
-                <div class="pb-2 pl-4 text-tiny text-secondaryLight">
-                  {{ t("action.group_by") }}
-                </div>
-                <HoppSmartRadioGroup
-                  v-model="groupSelection"
-                  :radios="groups"
-                  @update:model-value="hide()"
-                />
-              </div>
-            </template>
-          </tippy>
-          <HoppButtonSecondary
-            v-tippy="{ theme: 'tooltip' }"
-            data-testid="clear_history"
-            :disabled="
-              history.length === 0 ||
-              !isHistoryStoreEnabled ||
-              isFetchingHistoryStoreStatus
-            "
-            :icon="IconTrash2"
-            :title="t('action.clear_all')"
-            @click="confirmRemove = true"
-          />
-        </div>
-      </div>
-    </div>
-    <div
-      v-if="isHistoryStoreEnabled && !isFetchingHistoryStoreStatus"
-      class="flex flex-col"
-    >
-      <details
-        v-for="(
-          filteredHistoryGroup, filteredHistoryGroupIndex
-        ) in filteredHistoryGroups"
-        :key="`filteredHistoryGroup-${filteredHistoryGroupIndex}`"
-        class="flex flex-col"
-        open
-      >
-        <summary
-          class="group flex min-w-0 flex-1 cursor-pointer items-center justify-between text-tiny text-secondaryLight transition focus:outline-none"
-        >
-          <span
-            class="inline-flex items-center justify-center truncate px-4 py-2 transition group-hover:text-secondary"
-          >
-            <icon-lucide-chevron-right
-              class="indicator mr-2 flex flex-shrink-0"
-            />
-            <span
-              :class="[
-                { 'capitalize-first': groupSelection === 'TIME' },
-                'truncate',
-              ]"
-            >
-              {{ filteredHistoryGroupIndex }}
-            </span>
-          </span>
-          <HoppButtonSecondary
-            v-tippy="{ theme: 'tooltip' }"
-            :icon="IconTrash"
-            color="red"
-            :title="t('action.remove')"
-            class="hidden group-hover:inline-flex"
-            @click="deleteBatchHistoryEntry(filteredHistoryGroup)"
-          />
-        </summary>
-        <component
-          :is="page === 'rest' ? HistoryRestCard : HistoryGraphqlCard"
-          v-for="(entry, index) in filteredHistoryGroup"
-          :id="index"
-          :key="`entry-${index}`"
-          :entry="entry.entry"
-          :show-more="showMore"
-          @toggle-star="toggleStar(entry.entry)"
-          @delete-entry="deleteHistory(entry.entry)"
-          @use-entry="useHistory(toRaw(entry.entry))"
-          @add-to-collection="addToCollection(entry.entry)"
-        />
-      </details>
-    </div>
-    <HoppSmartPlaceholder
-      v-if="!isHistoryStoreEnabled && !isFetchingHistoryStoreStatus"
-      :src="`/images/states/${colorMode.value}/time.svg`"
-      :alt="`${t('empty.history')}`"
-      :text="t('settings.history_disabled')"
-    />
-    <HoppSmartPlaceholder
-      v-else-if="history.length === 0"
-      :src="`/images/states/${colorMode.value}/time.svg`"
-      :alt="`${t('empty.history')}`"
-      :text="t('empty.history')"
-    />
-
-    <HoppSmartPlaceholder
-      v-else-if="
-        Object.keys(filteredHistoryGroups).length === 0 ||
-        filteredHistory.length === 0
-      "
-      :text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
-    >
-      <template #icon>
-        <icon-lucide-search class="svg-icons opacity-75" />
-      </template>
-      <template #body>
-        <HoppButtonSecondary
-          :label="t('action.clear')"
-          outline
-          @click="
-            () => {
-              filterText = ''
-              filterSelection = 'ALL'
-            }
-          "
-        />
-      </template>
-    </HoppSmartPlaceholder>
-    <HoppSmartConfirmModal
-      :show="confirmRemove"
-      :title="`${t('confirm.remove_history')}`"
-      @hide-modal="confirmRemove = false"
-      @resolve="clearHistory"
-    />
-  </div>
+  <WorkspaceCurrent :section="section">
+    <template #item>
+      <component
+        :is="platform.ui?.additionalSidebarHeaderItem"
+        v-if="
+          props.selectedTab === 'history' &&
+          historyUIProviderService.isEnabled.value
+        "
+      />
+    </template>
+  </WorkspaceCurrent>
+  <HistoryPersonal
+    v-if="workspace === 'personal' || !historyUIProviderService.isEnabled.value"
+    :page="page"
+  />
+  <component :is="platform.ui?.additionalHistoryComponent" v-else />
 </template>
 
 <script setup lang="ts">
-import IconHelpCircle from "~icons/lucide/help-circle"
-import IconTrash2 from "~icons/lucide/trash-2"
-import IconTrash from "~icons/lucide/trash"
-import IconFilter from "~icons/lucide/filter"
-import { computed, ref, Ref, toRaw } from "vue"
-import { useColorMode } from "@composables/theming"
-import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
-import { groupBy, escapeRegExp, filter } from "lodash-es"
-import { useTimeAgo } from "@vueuse/core"
-import { pipe } from "fp-ts/function"
-import * as A from "fp-ts/Array"
-import { useI18n } from "@composables/i18n"
-import { useReadonlyStream } from "@composables/stream"
-import { useToast } from "@composables/toast"
-import {
-  restHistory$,
-  graphqlHistory$,
-  clearRESTHistory,
-  clearGraphqlHistory,
-  toggleGraphqlHistoryEntryStar,
-  toggleRESTHistoryEntryStar,
-  deleteGraphqlHistoryEntry,
-  deleteRESTHistoryEntry,
-  RESTHistoryEntry,
-  GQLHistoryEntry,
-} from "~/newstore/history"
-
-import HistoryRestCard from "./rest/Card.vue"
-import HistoryGraphqlCard from "./graphql/Card.vue"
-import { defineActionHandler, invokeAction } from "~/helpers/actions"
 import { useService } from "dioc/vue"
-import { RESTTabService } from "~/services/tab/rest"
+import { computed } from "vue"
+import { useI18n } from "~/composables/i18n"
 import { platform } from "~/platform"
+import { HistoryUIProviderService } from "~/services/history-ui-provider.service"
+import { WorkspaceService } from "~/services/workspace.service"
 
-type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
-
-type TimedHistoryEntry = {
-  entry: HistoryEntry
-  timeAgo: Ref<string>
-}
+const t = useI18n()
 
 const props = defineProps<{
   page: "rest" | "graphql"
+  selectedTab: string
 }>()
 
-const toast = useToast()
-const t = useI18n()
-const colorMode = useColorMode()
-
-const filterText = ref("")
-const showMore = ref(false)
-const confirmRemove = ref(false)
-
-const history = useReadonlyStream<RESTHistoryEntry[] | GQLHistoryEntry[]>(
-  props.page === "rest" ? restHistory$ : graphqlHistory$,
-  []
-)
-
-const { isHistoryStoreEnabled, isFetchingHistoryStoreStatus } =
-  "requestHistoryStore" in platform.sync.history &&
-  platform.sync.history.requestHistoryStore
-    ? platform.sync.history.requestHistoryStore
-    : {
-        isHistoryStoreEnabled: ref(true),
-        isFetchingHistoryStoreStatus: ref(false),
-      }
-
-const deepCheckForRegex = (value: unknown, regExp: RegExp): boolean => {
-  if (value === null || value === undefined) return false
-
-  if (typeof value === "string") return regExp.test(value)
-  if (typeof value === "number") return regExp.test(value.toString())
-
-  if (typeof value === "object")
-    return Object.values(value).some((input) =>
-      deepCheckForRegex(input, regExp)
-    )
-  if (Array.isArray(value))
-    return value.some((input) => deepCheckForRegex(input, regExp))
-
-  return false
-}
-
-const filteredHistory = computed(() =>
-  pipe(
-    history.value as HistoryEntry[],
-    A.filter(
-      (
-        input
-      ): input is HistoryEntry & {
-        updatedOn: NonNullable<HistoryEntry["updatedOn"]>
-      } => {
-        return (
-          !!input.updatedOn &&
-          (filterText.value.length === 0 ||
-            deepCheckForRegex(
-              input,
-              new RegExp(escapeRegExp(filterText.value), "gi")
-            ))
-        )
-      }
-    ),
-    A.map(
-      (entry): TimedHistoryEntry => ({
-        entry,
-        timeAgo: useTimeAgo(entry.updatedOn),
-      })
-    )
-  )
-)
-
-const filters = computed(() => [
-  { value: "ALL" as const, label: t("filter.all") },
-  { value: "STARRED" as const, label: t("filter.starred") },
-])
-
-type FilterMode = (typeof filters)["value"][number]["value"]
-
-const filterSelection = ref<FilterMode>("ALL")
-
-const groups = computed(() => [
-  { value: "TIME" as const, label: t("group.time") },
-  { value: "URL" as const, label: t("group.url") },
-])
+const workspaceService = useService(WorkspaceService)
+const historyUIProviderService = useService(HistoryUIProviderService)
 
-type GroupMode = (typeof groups)["value"][number]["value"]
-
-const groupSelection = ref<GroupMode>("TIME")
-
-const filteredHistoryGroups = computed(() =>
-  groupBy(
-    filter(filteredHistory.value, (input) =>
-      filterSelection.value === "STARRED" ? input.entry.star : true
-    ),
-    (input) =>
-      groupSelection.value === "TIME"
-        ? input.timeAgo.value
-        : getAppropriateURL(input.entry)
-  )
+const workspace = computed(() => workspaceService.currentWorkspace.value.type)
+const section = computed(() =>
+  workspace.value === "personal" || !historyUIProviderService.isEnabled.value
+    ? t("tab.history")
+    : historyUIProviderService.historyUIProviderTitle.value(t)
 )
-
-const getAppropriateURL = (entry: HistoryEntry) => {
-  if (props.page === "rest") {
-    return (entry.request as HoppRESTRequest).endpoint
-  } else if (props.page === "graphql") {
-    return (entry.request as HoppGQLRequest).url
-  }
-}
-
-const clearHistory = () => {
-  if (props.page === "rest") clearRESTHistory()
-  else clearGraphqlHistory()
-  toast.success(`${t("state.history_deleted")}`)
-}
-
-// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
-// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
-const tabs = useService(RESTTabService)
-const useHistory = (entry: RESTHistoryEntry) => {
-  tabs.createNewTab({
-    type: "request",
-    request: entry.request,
-    isDirty: false,
-  })
-}
-
-const isRESTHistoryEntry = (
-  entries: TimedHistoryEntry[]
-): entries is Array<TimedHistoryEntry & { entry: RESTHistoryEntry }> =>
-  // If the page is rest, then we can guarantee what we have is a RESTHistoryEnry
-  props.page === "rest"
-
-const deleteBatchHistoryEntry = (entries: TimedHistoryEntry[]) => {
-  if (isRESTHistoryEntry(entries)) {
-    entries.forEach((entry) => {
-      deleteRESTHistoryEntry(entry.entry)
-    })
-  } else {
-    entries.forEach((entry) => {
-      deleteGraphqlHistoryEntry(entry.entry as GQLHistoryEntry)
-    })
-  }
-  toast.success(`${t("state.deleted")}`)
-}
-
-const deleteHistory = (entry: HistoryEntry) => {
-  if (props.page === "rest") deleteRESTHistoryEntry(entry as RESTHistoryEntry)
-  else deleteGraphqlHistoryEntry(entry as GQLHistoryEntry)
-  toast.success(`${t("state.deleted")}`)
-}
-
-const addToCollection = (entry: HistoryEntry) => {
-  if (props.page === "rest") {
-    invokeAction("request.save-as", {
-      requestType: "rest",
-      request: entry.request as HoppRESTRequest,
-    })
-  }
-}
-
-const toggleStar = (entry: HistoryEntry) => {
-  // History entry type specified because function does not know the type
-  if (props.page === "rest")
-    toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
-  else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
-}
-
-defineActionHandler("history.clear", () => {
-  confirmRemove.value = true
-})
 </script>

+ 1 - 1
packages/hoppscotch-common/src/components/http/Sidebar.vue

@@ -24,7 +24,7 @@
       :icon="IconClock"
       :label="`${t('tab.history')}`"
     >
-      <History :page="'rest'" />
+      <History :page="'rest'" :selected-tab="selectedNavigationTab" />
     </HoppSmartTab>
     <HoppSmartTab
       :id="'share-request'"

+ 12 - 9
packages/hoppscotch-common/src/components/workspace/Current.vue

@@ -1,28 +1,31 @@
 <template>
   <div
-    class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
+    class="flex justify-between border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
   >
-    <span class="truncate">
-      {{ currentWorkspace }}
-    </span>
-    <icon-lucide-chevron-right v-if="section" class="mx-2" />
-    {{ section }}
+    <div class="flex items-center overflow-x-auto whitespace-nowrap">
+      <span class="truncate">
+        {{ currentWorkspace }}
+      </span>
+      <icon-lucide-chevron-right v-if="section" class="mx-2" />
+      {{ section }}
+    </div>
+    <slot name="item"></slot>
   </div>
 </template>
 
 <script setup lang="ts">
+import { useService } from "dioc/vue"
 import { computed } from "vue"
 import { useI18n } from "~/composables/i18n"
-import { useService } from "dioc/vue"
 import { WorkspaceService } from "~/services/workspace.service"
 
+const t = useI18n()
+
 const props = defineProps<{
   section?: string
   isOnlyPersonal?: boolean
 }>()
 
-const t = useI18n()
-
 const workspaceService = useService(WorkspaceService)
 const workspace = workspaceService.currentWorkspace
 

+ 10 - 0
packages/hoppscotch-common/src/platform/ui.ts

@@ -48,4 +48,14 @@ export type UIPlatformDef = {
    * Additional profile Section components in the profile page
    */
   additionalProfileSections?: Component[]
+
+  /**
+   * Custom history related components to be shown in the history page
+   */
+  additionalHistoryComponent?: Component
+
+  /**
+   * Custom sidebar header item to be shown in the sidebar header
+   */
+  additionalSidebarHeaderItem?: Component
 }

+ 25 - 0
packages/hoppscotch-common/src/services/__tests__/history-ui-provider.service.spec.ts

@@ -0,0 +1,25 @@
+import { TestContainer } from "dioc/testing"
+import { describe, expect, it } from "vitest"
+import { getI18n } from "~/modules/i18n"
+import { HistoryUIProviderService } from "../history-ui-provider.service"
+
+describe("HistoryUIProviderService", () => {
+  const container = new TestContainer()
+  const historyUI = container.bind(HistoryUIProviderService)
+
+  it("should initialize with default values", () => {
+    expect(historyUI.isEnabled.value).toBe(false)
+  })
+
+  it("should return correct default title", () => {
+    const mockT = ((key: string) => key) as ReturnType<typeof getI18n>
+    const title = historyUI.historyUIProviderTitle.value(mockT)
+    expect(title).toBe("tab.history")
+  })
+
+  it("should allow toggling enabled state", () => {
+    expect(historyUI.isEnabled.value).toBe(false)
+    historyUI.isEnabled.value = true
+    expect(historyUI.isEnabled.value).toBe(true)
+  })
+})

+ 18 - 0
packages/hoppscotch-common/src/services/history-ui-provider.service.ts

@@ -0,0 +1,18 @@
+import { Service } from "dioc"
+import { ref } from "vue"
+import { getI18n } from "~/modules/i18n"
+
+type HistoryUIProviderTitle = (t: ReturnType<typeof getI18n>) => string
+
+/**
+ * This service is used to provide custom UI items for the history section.
+ */
+export class HistoryUIProviderService extends Service {
+  public static readonly ID = "HISTORY_UI_PROVIDER_SERVICE"
+
+  public readonly isEnabled = ref<boolean>(false)
+
+  public readonly historyUIProviderTitle = ref<HistoryUIProviderTitle>((t) =>
+    t("tab.history")
+  )
+}