mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-04 20:13:48 -05:00
refactor(landing): replace untracked-projects fetcher with a DB query
The untracked-projects route was only ever loaded through a fetcher on mount (organization layout and the import/export settings), and refreshed as a side effect of every action's revalidation. - Add `untrackedProjectsQuery` keyed by the tracked organization ids, derived reactively from `useOrganizations()` instead of reading localStorage. - Invalidate it from `updateAppDataOnDbChanges` on project / workspace changes. - Count workspaces per project in parallel instead of serially. - Remove the `untracked-projects` route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2eb19f135
commit
cdb83a73b7
6 files changed
+123
-85
No files matched your search
@@ -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<typeof InsomniaData>('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> = {}): 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]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ChangeBufferEvent,
|
||||
CollectionWorkspaceChildren,
|
||||
OrganizationData,
|
||||
Project,
|
||||
Workspace,
|
||||
WorkspaceChildren,
|
||||
WorkspaceChildrenForScope,
|
||||
WorkspaceMeta,
|
||||
@@ -40,6 +42,39 @@ export const workspaceChildrenQuery = <S extends WorkspaceScope | undefined = un
|
||||
queryFn: async () => (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<UntrackedProjectsData> => {
|
||||
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<string>();
|
||||
const workspaceIdsToRevalidate = new Set<string>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user