diff --git a/packages/insomnia/src/common/__tests__/app-data.test.ts b/packages/insomnia/src/common/__tests__/app-data.test.ts index 60fe450d70..57410eb34f 100644 --- a/packages/insomnia/src/common/__tests__/app-data.test.ts +++ b/packages/insomnia/src/common/__tests__/app-data.test.ts @@ -3,7 +3,7 @@ import type * as InsomniaData from 'insomnia-data'; import type { BaseModel, ChangeBufferEvent, CollectionWorkspaceChildren, OrganizationData } from 'insomnia-data'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { organizationDataKeys, workspaceChildrenKeys } from '~/common/app-data'; +import { organizationDataKeys, untrackedProjectsKeys, workspaceChildrenKeys } from '~/common/app-data'; vi.mock('insomnia-data', async () => { const actual = await vi.importActual('insomnia-data'); @@ -13,13 +13,15 @@ vi.mock('insomnia-data', async () => { appData: { getWorkspaceChildren: vi.fn(), }, + project: { list: vi.fn() }, + workspace: { list: vi.fn(), count: vi.fn() }, }, }; }); import { services } from 'insomnia-data'; -import { prefetchUncachedWorkspaceChildren, updateAppDataOnDbChanges } from '../app-data'; +import { prefetchUncachedWorkspaceChildren, untrackedProjectsQuery, updateAppDataOnDbChanges } from '../app-data'; function fakeDoc(type: BaseModel['type'], overrides: Partial = {}): BaseModel { return { @@ -200,6 +202,22 @@ describe('updateAppDataOnDbChanges', () => { expect(queryClient.getQueryState(workspaceChildrenKeys.byWorkspaceId(workspaceId))?.isInvalidated).toBe(true); }); + it('invalidates untracked projects on project and workspace changes, but not on workspace meta changes', () => { + const key = untrackedProjectsKeys.byTrackedOrganizationIds(['org_1']); + const isInvalidated = () => queryClient.getQueryState(key)?.isInvalidated; + + queryClient.setQueryData(key, { untrackedProjects: [], untrackedWorkspaces: [] }); + updateAppDataOnDbChanges(queryClient, [change('update', fakeDoc('WorkspaceMeta', { parentId: 'wrk_1' }))]); + expect(isInvalidated()).toBe(false); + + updateAppDataOnDbChanges(queryClient, [change('insert', fakeDoc('Project', { parentId: 'org_gone' }))]); + expect(isInvalidated()).toBe(true); + + queryClient.setQueryData(key, { untrackedProjects: [], untrackedWorkspaces: [] }); + updateAppDataOnDbChanges(queryClient, [change('remove', fakeDoc('Workspace', { parentId: 'proj_1' }))]); + expect(isInvalidated()).toBe(true); + }); + it('ignores doc types it does not monitor', () => { const settings = fakeDoc('Settings', { _id: 'settings_1' }); expect(() => updateAppDataOnDbChanges(queryClient, [change('update', settings)])).not.toThrow(); @@ -270,3 +288,28 @@ describe('prefetchUncachedWorkspaceChildren', () => { expect(services.appData.getWorkspaceChildren).not.toHaveBeenCalled(); }); }); + +describe('untrackedProjectsQuery', () => { + it('lists projects outside the tracked organizations with their workspace counts, plus orphaned workspaces', async () => { + const orphanWorkspace = fakeDoc('Workspace', { _id: 'wrk_orphan', parentId: null as unknown as string }); + vi.mocked(services.project.list).mockResolvedValue([ + fakeDoc('Project', { _id: 'proj_a' }), + fakeDoc('Project', { _id: 'proj_b' }), + ] as any); + vi.mocked(services.workspace.list).mockResolvedValue([orphanWorkspace] as any); + vi.mocked(services.workspace.count).mockImplementation(async query => + (query as { parentId: string }).parentId === 'proj_a' ? 2 : 0, + ); + + const queryClient = new QueryClient(); + const result = await queryClient.fetchQuery(untrackedProjectsQuery(['org_1', 'org_2'])); + + expect(services.project.list).toHaveBeenCalledWith({ parentId: { $nin: ['org_1', 'org_2'] } }); + expect(services.workspace.list).toHaveBeenCalledWith({ parentId: null }); + expect(result.untrackedProjects.map(p => [p._id, p.workspacesCount])).toEqual([ + ['proj_a', 2], + ['proj_b', 0], + ]); + expect(result.untrackedWorkspaces).toEqual([orphanWorkspace]); + }); +}); diff --git a/packages/insomnia/src/common/app-data.ts b/packages/insomnia/src/common/app-data.ts index b51a14e93a..2bad731030 100644 --- a/packages/insomnia/src/common/app-data.ts +++ b/packages/insomnia/src/common/app-data.ts @@ -4,6 +4,8 @@ import type { ChangeBufferEvent, CollectionWorkspaceChildren, OrganizationData, + Project, + Workspace, WorkspaceChildren, WorkspaceChildrenForScope, WorkspaceMeta, @@ -40,6 +42,39 @@ export const workspaceChildrenQuery = (await services.appData.getWorkspaceChildren([workspaceId], scope)).get(workspaceId), }); +export const untrackedProjectsKeys = { + all: ['untracked-projects'], + byTrackedOrganizationIds: (trackedOrganizationIds: string[]) => [ + ...untrackedProjectsKeys.all, + ...trackedOrganizationIds, + ], +}; + +export interface UntrackedProjectsData { + untrackedProjects: (Project & { workspacesCount: number })[]; + untrackedWorkspaces: Workspace[]; +} + +// Projects whose parent is none of the tracked organizations, and workspaces with no project. +// Invalidated by `updateAppDataOnDbChanges` on any project / workspace change. +export const untrackedProjectsQuery = (trackedOrganizationIds: string[]) => + queryOptions({ + queryKey: untrackedProjectsKeys.byTrackedOrganizationIds(trackedOrganizationIds), + queryFn: async (): Promise => { + const [projects, untrackedWorkspaces] = await Promise.all([ + services.project.list({ parentId: { $nin: trackedOrganizationIds } }), + services.workspace.list({ parentId: null }), + ]); + const untrackedProjects = await Promise.all( + projects.map(async project => ({ + ...project, + workspacesCount: await services.workspace.count({ parentId: project._id }), + })), + ); + return { untrackedProjects, untrackedWorkspaces }; + }, + }); + const COLLECTION_REQUEST_DOC_TYPES: string[] = [ models.request.type, models.grpcRequest.type, @@ -258,6 +293,7 @@ function updateCollectionChildrenWithUpdatedDoc( export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: ChangeBufferEvent[]) { const organizationIdsToRevalidate = new Set(); const workspaceIdsToRevalidate = new Set(); + let shouldRevalidateUntrackedProjects = false; // We do not use the patches here because some db operations do not contain patches like git repo file watcher for (const [event, doc] of changes) { @@ -266,6 +302,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan } if (doc.type === models.project.type) { + shouldRevalidateUntrackedProjects = true; organizationIdsToRevalidate.add(doc.parentId); // The project may have moved from a different, already-cached organization (e.g. the Konnect // migration re-parents projects across organizations) — find and revalidate its old one too, @@ -281,6 +318,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan } if (doc.type === models.workspace.type) { + shouldRevalidateUntrackedProjects = true; const { organizationId } = findOrganizationAndProjectIdForWorkspace(queryClient, doc) || {}; if (organizationId) { organizationIdsToRevalidate.add(organizationId); @@ -393,4 +431,7 @@ export function updateAppDataOnDbChanges(queryClient: QueryClient, changes: Chan refetchType: 'all', }), ); + if (shouldRevalidateUntrackedProjects) { + queryClient.invalidateQueries({ queryKey: untrackedProjectsKeys.all }); + } } diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index 839601fb2a..2b13be04e3 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -10,7 +10,6 @@ import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organizati import { detectKonnectOrgMigration } from '~/konnect/migrate-konnect-organization'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects'; -import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects'; import { AnalyticsEvent } from '~/ui/analytics'; import { useEditorHistoryTabCleanup } from '~/ui/components/.client/codemirror/editor-history-cleanup'; import { CommandPalette } from '~/ui/components/command-palette'; @@ -35,6 +34,7 @@ import { RunnerProvider } from '~/ui/context/app/runner-context'; import { useCurrentPlan, useCurrentUser, useOrganizations } from '~/ui/hooks/use-account-server-data'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data'; +import { useUntrackedProjects } from '~/ui/hooks/use-untracked-projects'; import { refreshKonnectAccess } from '~/ui/organization-utils'; import type { AsyncTask } from '~/ui/utils/router'; @@ -185,7 +185,6 @@ const Component = () => { const navigate = useNavigate(); const isScratchpadWorkspace = workspaceData?.activeWorkspace && models.workspace.isScratchpad(workspaceData.activeWorkspace); - const untrackedProjectsFetcher = useUntrackedProjectsLoaderFetcher(); const { organizationId, projectId } = useParams() as { organizationId: string; projectId?: string; @@ -219,13 +218,6 @@ const Component = () => { } }, [organizationId, asyncTaskList, syncOrgsAndProjects]); - useEffect(() => { - const isIdleAndUninitialized = untrackedProjectsFetcher.state === 'idle' && !untrackedProjectsFetcher.data; - if (isIdleAndUninitialized) { - untrackedProjectsFetcher.load(); - } - }, [organizationId, untrackedProjectsFetcher]); - useEffect(() => { window.main.setCurrentOrganizationId(organizationId); return () => window.main.setCurrentOrganizationId(undefined); @@ -243,8 +235,7 @@ const Component = () => { }); }, [userSession.accountId]); - const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || []; - const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || []; + const { untrackedProjects, untrackedWorkspaces } = useUntrackedProjects(); const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0; const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID; const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId); diff --git a/packages/insomnia/src/routes/untracked-projects.tsx b/packages/insomnia/src/routes/untracked-projects.tsx deleted file mode 100644 index cf60d5b471..0000000000 --- a/packages/insomnia/src/routes/untracked-projects.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Organization } from 'insomnia-api'; -import type { Project, Workspace } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; - -import { createFetcherLoadHook } from '~/ui/utils/router'; - -import type { Route } from './+types/untracked-projects'; - -export interface UntrackedProjectsLoaderData { - untrackedProjects: (Project & { workspacesCount: number })[]; - untrackedWorkspaces: Workspace[]; -} - -export async function clientLoader(_args: Route.ClientLoaderArgs) { - const { accountId } = await services.userSession.get(); - const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; - const listOfOrganizationIds = [ - ...organizations.map(o => o.id), - models.organization.SCRATCHPAD_ORGANIZATION_ID, - models.organization.getKonnectOrganizationId(accountId), - ]; - - const projects = await services.project.list({ - parentId: { $nin: listOfOrganizationIds }, - }); - const untrackedProjects = []; - - for (const project of projects) { - const workspacesCount = await services.workspace.count({ - parentId: project._id, - }); - - untrackedProjects.push({ - ...project, - workspacesCount, - }); - } - - const untrackedWorkspaces = await services.workspace.list({ - parentId: null, - }); - - return { - untrackedProjects, - untrackedWorkspaces, - }; -} - -export const useUntrackedProjectsLoaderFetcher = createFetcherLoadHook( - load => () => { - return load('/untracked-projects'); - }, - clientLoader, -); diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index 1c00c3c00f..b740156d91 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -1,8 +1,5 @@ import { format } from 'date-fns'; -import { - convertApiSpecSyntax, - detectApiSpecSyntax, -} from 'insomnia/src/common/api-specs'; +import { convertApiSpecSyntax, detectApiSpecSyntax } from 'insomnia/src/common/api-specs'; import { getProductName } from 'insomnia/src/common/constants'; import { getWorkspaceLabel } from 'insomnia/src/common/get-workspace-label'; import { getInsomniaV5DataExport } from 'insomnia/src/common/insomnia-v5'; @@ -26,13 +23,13 @@ import { useProjectListWorkspacesLoaderFetcher } from '~/routes/organization.$or import { useProjectMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move'; import { useProjectMoveWorkspaceActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.move-workspace'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { useUntrackedProjectsLoaderFetcher } from '~/routes/untracked-projects'; import { AlertModal } from '~/ui/components/modals/alert-modal'; import { ImportProjectsModal } from '~/ui/components/modals/import-modal/import-projects-modal'; import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { usePlanData } from '~/ui/hooks/use-plan'; import { useRootLoaderData } from '~/ui/hooks/use-root-loader-data'; +import { useUntrackedProjects } from '~/ui/hooks/use-untracked-projects'; const VALUE_YAML = 'yaml'; const VALUE_JSON = 'json'; @@ -714,17 +711,7 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => const { features } = useOrganizationPermissions(); const { isEnterprisePlan } = usePlanData(); - const untrackedProjectsFetcher = useUntrackedProjectsLoaderFetcher(); - - useEffect(() => { - const isIdleAndUninitialized = untrackedProjectsFetcher.state === 'idle' && !untrackedProjectsFetcher.data; - if (isIdleAndUninitialized) { - untrackedProjectsFetcher.load(); - } - }, [untrackedProjectsFetcher, organizationId]); - - const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || []; - const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || []; + const { untrackedProjects, untrackedWorkspaces } = useUntrackedProjects(); const workspaceData = useWorkspaceLoaderData(); const activeWorkspaceName = workspaceData?.activeWorkspace.name; @@ -755,8 +742,7 @@ export const ImportExport: FC = ({ hideSettingsModal, onModalChange }) => const activeProject = projectLoaderData?.activeProject; const projectName = activeProject?.name ?? getProductName(); const projects = projectLoaderData?.projects || []; - const organizationName = - organizations.find(org => org.id === organizationId)?.name || 'Organization'; + const organizationName = organizations.find(org => org.id === organizationId)?.name || 'Organization'; const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportProjectsModalOpen, setIsImportProjectsModalOpen] = useState(false); diff --git a/packages/insomnia/src/ui/hooks/use-untracked-projects.ts b/packages/insomnia/src/ui/hooks/use-untracked-projects.ts new file mode 100644 index 0000000000..c9af8dc83b --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-untracked-projects.ts @@ -0,0 +1,31 @@ +import { models } from 'insomnia-data'; +import { useMemo } from 'react'; + +import { type UntrackedProjectsData, untrackedProjectsQuery } from '~/common/app-data'; +import { useRootLoaderData } from '~/root'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; +import { useDBQuery } from '~/ui/hooks/use-query'; + +const emptyData: UntrackedProjectsData = { untrackedProjects: [], untrackedWorkspaces: [] }; + +/** Projects that belong to none of the account's organizations, and workspaces that belong to no project. */ +export function useUntrackedProjects(): UntrackedProjectsData { + const { userSession } = useRootLoaderData()!; + const organizations = useOrganizations(); + const accountId = userSession.accountId; + + const trackedOrganizationIds = useMemo( + () => + [ + ...new Set([ + ...organizations.map(organization => organization.id), + models.organization.SCRATCHPAD_ORGANIZATION_ID, + models.organization.getKonnectOrganizationId(accountId), + ]), + ].sort(), + [organizations, accountId], + ); + + const { data } = useDBQuery(untrackedProjectsQuery(trackedOrganizationIds)); + return data ?? emptyData; +}