mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
fix(untracked-workspaces): Migrate untracked workspaces and display them in the UI (#6883)
* Initial automatic migration Co-authored-by: Mark Kim <marckong@users.noreply.github.com> * add continue statement * add untracked workspaces ui in the data tab * fix moving a project * fix sentry hack --------- Co-authored-by: Mark Kim <marckong@users.noreply.github.com> Co-authored-by: Mark Kim <mark.kim@konghq.com>
This commit is contained in:
parent
2ca5ca38a1
commit
eda7df8364
@ -4,7 +4,6 @@ import type { SentryRequestType } from '@sentry/types';
|
||||
import * as session from '../account/session';
|
||||
import { ChangeBufferEvent, database as db } from '../common/database';
|
||||
import { SENTRY_OPTIONS } from '../common/sentry';
|
||||
import { ExceptionCallback, registerCaptureException } from '../models/capture-exception.util';
|
||||
import * as models from '../models/index';
|
||||
import { isSettings } from '../models/settings';
|
||||
|
||||
@ -24,6 +23,12 @@ export function sentryWatchAnalyticsEnabled() {
|
||||
if (isSettings(doc) && event === 'update') {
|
||||
enabled = doc.enableAnalytics || session.isLoggedIn();
|
||||
}
|
||||
|
||||
if (event === 'insert' || event === 'update') {
|
||||
if ([models.workspace.type, models.project.type].includes(doc.type) && !doc.parentId) {
|
||||
Sentry.captureException(new Error(`Missing parent ID for ${doc.type} on ${event}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -45,8 +50,4 @@ export function initializeSentry() {
|
||||
...SENTRY_OPTIONS,
|
||||
transport: ElectronSwitchableTransport,
|
||||
});
|
||||
|
||||
// this is a hack for logging the sentry error synthetically made for database parent id null issue
|
||||
// currently the database modules are used in the inso-cli as well as it uses NeDB (why?)
|
||||
registerCaptureException(Sentry.captureException as ExceptionCallback);
|
||||
}
|
||||
|
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* This is a HACK to work around inso cli using the database module used for Insomnia desktop client.
|
||||
* Now this is getting coupled with Electron side, and CLI should not be really related to the electron at all.
|
||||
* That is another tech debt.
|
||||
*/
|
||||
export type ExceptionCallback = (exception: unknown, captureContext?: unknown) => string;
|
||||
|
||||
let captureException: ExceptionCallback = (exception: unknown) => {
|
||||
console.error(exception);
|
||||
return '';
|
||||
};
|
||||
|
||||
export function loadCaptureException() {
|
||||
return captureException;
|
||||
}
|
||||
|
||||
export function registerCaptureException(fn: ExceptionCallback) {
|
||||
captureException = fn;
|
||||
}
|
@ -16,7 +16,6 @@ import {
|
||||
import { generateId } from '../common/misc';
|
||||
import * as _apiSpec from './api-spec';
|
||||
import * as _caCertificate from './ca-certificate';
|
||||
import { loadCaptureException } from './capture-exception.util';
|
||||
import * as _clientCertificate from './client-certificate';
|
||||
import * as _cookieJar from './cookie-jar';
|
||||
import * as _environment from './environment';
|
||||
@ -164,17 +163,6 @@ export function canDuplicate(type: string) {
|
||||
return model ? model.canDuplicate : false;
|
||||
}
|
||||
|
||||
const assertModelWithParentId = (model: BaseModel, info: string) => {
|
||||
if ((model.type === 'Project' || model.type === 'Workspace') && !model.parentId) {
|
||||
const msg = `[bug] parent id is set null unexpectedly ${model.type} - ${model._id}. ${info}`;
|
||||
console.warn(msg);
|
||||
|
||||
const err = new Error(msg);
|
||||
const capture = loadCaptureException();
|
||||
capture(err);
|
||||
}
|
||||
};
|
||||
|
||||
export async function initModel<T extends BaseModel>(type: string, ...sources: Record<string, any>[]): Promise<T> {
|
||||
const model = getModel(type);
|
||||
|
||||
@ -198,7 +186,6 @@ export async function initModel<T extends BaseModel>(type: string, ...sources: R
|
||||
model.init(),
|
||||
);
|
||||
const fullObject = Object.assign({}, objectDefaults, ...sources);
|
||||
assertModelWithParentId(fullObject, 'initModel');
|
||||
|
||||
// Generate an _id if there isn't one yet
|
||||
if (!fullObject._id) {
|
||||
@ -208,7 +195,6 @@ export async function initModel<T extends BaseModel>(type: string, ...sources: R
|
||||
// Migrate the model
|
||||
// NOTE: Do migration before pruning because we might need to look at those fields
|
||||
const migratedDoc = model.migrate(fullObject);
|
||||
assertModelWithParentId(migratedDoc, 'model.migrate');
|
||||
// Prune extra keys from doc
|
||||
for (const key of Object.keys(migratedDoc)) {
|
||||
if (!objectDefaults.hasOwnProperty(key)) {
|
||||
@ -217,8 +203,6 @@ export async function initModel<T extends BaseModel>(type: string, ...sources: R
|
||||
}
|
||||
}
|
||||
|
||||
assertModelWithParentId(migratedDoc, 'model.migrate after prune');
|
||||
|
||||
// @ts-expect-error -- TSCONVERSION not sure why this error is occurring
|
||||
return migratedDoc;
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import { getWorkspaceLabel } from '../../../common/get-workspace-label';
|
||||
import { strings } from '../../../common/strings';
|
||||
import { isScratchpadOrganizationId, Organization } from '../../../models/organization';
|
||||
import { Project } from '../../../models/project';
|
||||
import { isScratchpad } from '../../../models/workspace';
|
||||
import { isScratchpad, Workspace } from '../../../models/workspace';
|
||||
import { SegmentEvent } from '../../analytics';
|
||||
import { useOrganizationLoaderData } from '../../routes/organization';
|
||||
import { ProjectLoaderData } from '../../routes/project';
|
||||
@ -123,6 +123,106 @@ const UntrackedProject = ({
|
||||
);
|
||||
};
|
||||
|
||||
const UntrackedWorkspace = ({
|
||||
workspace,
|
||||
organizationId,
|
||||
projects,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
organizationId: string;
|
||||
projects: Project[];
|
||||
}) => {
|
||||
const moveWorkspaceFetcher = useFetcher();
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div key={workspace._id} className="flex items-center gap-2 justify-between py-2">
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Heading className='text-base font-semibold flex items-center gap-2'>
|
||||
{workspace.name}
|
||||
<span className='text-xs text-[--hl]'>
|
||||
Id: {workspace._id}
|
||||
</span>
|
||||
</Heading>
|
||||
</div>
|
||||
<moveWorkspaceFetcher.Form
|
||||
action={`/organization/${organizationId}/project/${selectedProjectId}/move-workspace`}
|
||||
method='POST'
|
||||
className='group flex items-center gap-2'
|
||||
>
|
||||
<input type="hidden" name="workspaceId" value={workspace._id} />
|
||||
<Select
|
||||
aria-label="Select a project"
|
||||
name="projectId"
|
||||
onSelectionChange={key => {
|
||||
setSelectedProjectId(key.toString());
|
||||
}}
|
||||
selectedKey={selectedProjectId}
|
||||
isDisabled={projects.length === 0}
|
||||
>
|
||||
<Button className="px-4 py-1 disabled:bg-[--hl-xs] disabled:cursor-not-allowed font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] data-[pressed]:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
|
||||
<SelectValue<Project> className="flex truncate items-center justify-center gap-2">
|
||||
{({ selectedItem }) => {
|
||||
if (!selectedItem) {
|
||||
return (
|
||||
<Fragment>
|
||||
<span>
|
||||
Select a project
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{selectedItem.name}
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
</SelectValue>
|
||||
<Icon icon="caret-down" />
|
||||
</Button>
|
||||
<Popover className="min-w-max">
|
||||
<ListBox
|
||||
className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none"
|
||||
items={projects.map(project => ({
|
||||
...project,
|
||||
id: project._id,
|
||||
}))}
|
||||
>
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
|
||||
aria-label={item.name}
|
||||
textValue={item.name}
|
||||
value={item}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<Fragment>
|
||||
{item.name}
|
||||
{isSelected && (
|
||||
<Icon
|
||||
icon="check"
|
||||
className="text-[--color-success] justify-self-end"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</Select>
|
||||
<Button isDisabled={projects.length === 0 || !selectedProjectId || moveWorkspaceFetcher.state !== 'idle'} type="submit" className="px-4 py-1 group-invalid:opacity-30 disabled:bg-[--hl-xs] disabled:cursor-not-allowed font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
|
||||
Move
|
||||
</Button>
|
||||
</moveWorkspaceFetcher.Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
hideSettingsModal: () => void;
|
||||
}
|
||||
@ -146,6 +246,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
|
||||
}, [untrackedProjectsFetcher, organizationId]);
|
||||
|
||||
const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || [];
|
||||
const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || [];
|
||||
|
||||
const workspaceData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | undefined;
|
||||
const activeWorkspaceName = workspaceData?.activeWorkspace.name;
|
||||
@ -160,6 +261,7 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
|
||||
const projectLoaderData = workspacesFetcher?.data as ProjectLoaderData | undefined;
|
||||
const workspacesForActiveProject = projectLoaderData?.workspaces.map(w => w.workspace) || [];
|
||||
const projectName = projectLoaderData?.activeProject.name ?? getProductName();
|
||||
const projects = projectLoaderData?.projects || [];
|
||||
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
@ -303,6 +405,24 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
{untrackedWorkspaces.length > 0 && projects.length > 0 && <div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="cancel" /> Untracked files ({untrackedWorkspaces.length})</Heading>
|
||||
<p className='text-[--hl] text-sm'>
|
||||
<Icon icon="info-circle" /> These files are not associated with any project in your account. You can move them to a project in your current organization bellow.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 overflow-y-auto divide-y divide-solid divide-[--hl-md]'>
|
||||
{untrackedWorkspaces.map(workspace => (
|
||||
<UntrackedWorkspace
|
||||
key={workspace._id}
|
||||
workspace={workspace}
|
||||
organizationId={organizationId}
|
||||
projects={projects}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{isImportModalOpen && (
|
||||
<ImportModal
|
||||
|
@ -227,6 +227,13 @@ const router = createMemoryRouter(
|
||||
await import('./routes/actions')
|
||||
).moveProjectAction(...args),
|
||||
},
|
||||
{
|
||||
path: 'move-workspace',
|
||||
action: async (...args) =>
|
||||
(
|
||||
await import('./routes/actions')
|
||||
).moveWorkspaceIntoProjectAction(...args),
|
||||
},
|
||||
{
|
||||
path: 'update',
|
||||
action: async (...args) =>
|
||||
|
@ -437,6 +437,31 @@ export const updateWorkspaceAction: ActionFunction = async ({ request }) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const moveWorkspaceIntoProjectAction: ActionFunction = async ({ request, params }) => {
|
||||
const {
|
||||
organizationId,
|
||||
} = params;
|
||||
|
||||
invariant(typeof organizationId === 'string', 'Organization ID is required');
|
||||
|
||||
const formData = await request.formData();
|
||||
const projectId = formData.get('projectId');
|
||||
const workspaceId = formData.get('workspaceId');
|
||||
invariant(typeof projectId === 'string', 'Project ID is required');
|
||||
const project = await models.project.getById(projectId);
|
||||
invariant(project, 'Project not found');
|
||||
|
||||
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||
const workspace = await models.workspace.getById(workspaceId);
|
||||
invariant(workspace, 'Workspace not found');
|
||||
|
||||
await models.workspace.update(workspace, {
|
||||
parentId: projectId,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const updateWorkspaceMetaAction: ActionFunction = async ({ request, params }) => {
|
||||
const { workspaceId } = params;
|
||||
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
|
||||
|
@ -3,10 +3,12 @@ import { LoaderFunction } from 'react-router-dom';
|
||||
import { database } from '../../common/database';
|
||||
import { SCRATCHPAD_ORGANIZATION_ID } from '../../models/organization';
|
||||
import { Project } from '../../models/project';
|
||||
import { Workspace } from '../../models/workspace';
|
||||
import { organizationsData } from './organization';
|
||||
|
||||
export interface LoaderData {
|
||||
untrackedProjects: (Project & { workspacesCount: number })[];
|
||||
untrackedWorkspaces: Workspace[];
|
||||
}
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
@ -30,7 +32,12 @@ export const loader: LoaderFunction = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const untrackedWorkspaces = await database.find<Workspace>('Workspace', {
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
return {
|
||||
untrackedProjects,
|
||||
untrackedWorkspaces,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user