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:
James Gatz 2023-12-14 15:48:18 +01:00 committed by GitHub
parent 2ca5ca38a1
commit eda7df8364
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 166 additions and 41 deletions

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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) =>

View File

@ -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');

View File

@ -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,
};
};